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 07586cb74..3d3e4a24b 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 @@ -606,7 +606,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { world, area, terrainTargets, this::resolvePaletteBlock); } catch (Throwable error) { throw NativeStructureGenerationException.failure( - "terrain carving", nativeStructureBatchContext(placementGroups), + "terrain preparation", nativeStructureBatchContext(placementGroups), chunkPos.x(), chunkPos.z(), error); } for (NativePlacementGroup group : placementGroups) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java index ac4737af0..60489ca06 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorFailureContractTest.java @@ -44,6 +44,8 @@ public class IrisChunkGeneratorFailureContractTest { assertFalse(adjustment.contains("IrisLogging.reportError")); assertTrue(placement.contains("\"resolution\"")); assertTrue(placement.contains("\"terrain integration\"")); + assertTrue(placement.contains("\"terrain preparation\"")); + assertFalse(placement.contains("\"terrain carving\"")); assertTrue(placement.contains("\"vegetation cleanup\"")); assertTrue(placement.contains("\"placement\"")); assertTrue(placement.contains("because structure generation is disabled outside the pack")); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecoveryTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecoveryTest.java index 51775f427..e4bee55cf 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecoveryTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecoveryTest.java @@ -16,6 +16,7 @@ import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.levelgen.LegacyRandomSource; import net.minecraft.world.level.levelgen.RandomSupport; import net.minecraft.world.level.levelgen.WorldgenRandom; +import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.StructurePiece; import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; @@ -31,6 +32,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; public class NativeStructureOwnershipRecoveryTest { @@ -138,6 +140,110 @@ public class NativeStructureOwnershipRecoveryTest { assertFalse(NativeStructureOwnershipFingerprint.matches(recovered, moved)); } + @Test + public void staleVacuumEnvelopeRefreshesWithoutReplacingOwnershipIdentity() { + String structureKey = "minecraft:monument"; + long seed = 648231L; + ChunkPos origin = new ChunkPos(5, -6); + OceanMonumentStructure structure = structure(); + StructureStart start = monumentStart(structure, origin, seed); + NativeStructureStartPlan plan = plan( + origin, "vacuum-envelope-refresh", IrisStructureTerrainMode.VACUUM, 0); + BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(start); + NativeStructureOwnershipRecord stale = NativeStructureOwnershipFingerprint.capture( + structureKey, start, plan, content); + + NativeStructureOwnershipRecord refreshed = + NativeStructureOwnershipRecovery.refreshReferenceEnvelope( + structureKey, structure, start, stale); + + assertNotNull(refreshed); + assertNotEquals(stale, refreshed); + assertEquals(stale.schema(), refreshed.schema()); + assertEquals(stale.ownershipKey(), refreshed.ownershipKey()); + assertEquals(stale.placementIdentity(), refreshed.placementIdentity()); + assertEquals(stale.baseY(), refreshed.baseY()); + assertEquals(stale.locatorY(), refreshed.locatorY()); + assertEquals(stale.contentFingerprint(), refreshed.contentFingerprint()); + assertEquals(stale.decision(), refreshed.decision()); + assertEquals(IrisStructureTerrainMode.VACUUM, + refreshed.restoredDecision().terrain().resolvedMode()); + BoundingBox expected = NativeStructureReferenceEnvelope.referenceBounds( + start, structure, plan.placement().resolvedTerrain(), structureKey); + assertEquals(expected.minX() >> 4, refreshed.referenceMinChunkX()); + assertEquals(expected.maxX() >> 4, refreshed.referenceMaxChunkX()); + assertEquals(expected.minZ() >> 4, refreshed.referenceMinChunkZ()); + assertEquals(expected.maxZ() >> 4, refreshed.referenceMaxChunkZ()); + assertTrue(hasExpandedCoverage(stale, refreshed)); + assertSame(refreshed, NativeStructureOwnershipRecovery.refreshReferenceEnvelope( + structureKey, structure, start, refreshed)); + } + + @Test + public void staleEnvelopeCannotRefreshAgainstDifferentContent() { + String structureKey = "minecraft:monument"; + long seed = 412987L; + ChunkPos origin = new ChunkPos(-2, 7); + OceanMonumentStructure structure = structure(); + StructureStart expected = monumentStart(structure, origin, seed); + NativeStructureStartPlan plan = plan( + origin, "vacuum-content-check", IrisStructureTerrainMode.VACUUM, 0); + NativeStructureOwnershipRecord stale = NativeStructureOwnershipFingerprint.capture( + structureKey, expected, plan, + NativeStructureReferenceEnvelope.contentBounds(expected)); + StructureStart moved = monumentStart(structure, origin, seed); + for (StructurePiece piece : moved.getPieces()) { + piece.move(1, 0, 0); + } + + assertNull(NativeStructureOwnershipRecovery.refreshReferenceEnvelope( + structureKey, structure, moved, stale)); + } + + @Test + public void currentNonVacuumEnvelopeRemainsThePersistedAuthority() { + String structureKey = "minecraft:monument"; + long seed = 927451L; + ChunkPos origin = new ChunkPos(3, 8); + OceanMonumentStructure structure = structure(); + StructureStart start = monumentStart(structure, origin, seed); + NativeStructureStartPlan plan = plan(origin, "current-force-carve", 24); + BoundingBox envelope = NativeStructureReferenceEnvelope.referenceBounds( + start, structure, plan.placement().resolvedTerrain(), structureKey); + NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture( + structureKey, start, plan, envelope); + + assertSame(ownership, NativeStructureOwnershipRecovery.refreshReferenceEnvelope( + structureKey, structure, start, ownership)); + } + + @Test + public void clippedVacuumEnvelopeRemainsStableAtTheReferenceLimit() { + String structureKey = "minecraft:monument"; + long seed = 381729L; + ChunkPos origin = new ChunkPos(0, 0); + OceanMonumentStructure structure = structure(); + StructureStart start = monumentStart(structure, origin, seed); + BoundingBox initial = NativeStructureReferenceEnvelope.contentBounds(start); + int maximumReferenceBlockX = ((origin.x() + + NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS) << 4) + 15; + int shiftX = maximumReferenceBlockX - initial.maxX(); + for (StructurePiece piece : start.getPieces()) { + piece.move(shiftX, 0, 0); + } + NativeStructureStartPlan plan = plan( + origin, "clipped-vacuum", IrisStructureTerrainMode.VACUUM, 0); + BoundingBox envelope = NativeStructureReferenceEnvelope.referenceBounds( + start, structure, plan.placement().resolvedTerrain(), structureKey); + NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture( + structureKey, start, plan, envelope); + + assertEquals(origin.x() + NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS, + ownership.referenceMaxChunkX()); + assertSame(ownership, NativeStructureOwnershipRecovery.refreshReferenceEnvelope( + structureKey, structure, start, ownership)); + } + private static OceanMonumentStructure structure() { return new OceanMonumentStructure( new OceanMonumentStructure.StructureSettings(HolderSet.empty())); @@ -163,11 +269,19 @@ public class NativeStructureOwnershipRecoveryTest { private static NativeStructureStartPlan plan(ChunkPos origin, String placementId, int horizontalPadding) { + return plan(origin, placementId, + IrisStructureTerrainMode.FORCE_CARVE, horizontalPadding); + } + + private static NativeStructureStartPlan plan(ChunkPos origin, + String placementId, + IrisStructureTerrainMode terrainMode, + int horizontalPadding) { IrisNativeStructure source = new IrisNativeStructure() .setStructure("minecraft:monument") .setWeight(1); IrisStructureTerrain terrain = new IrisStructureTerrain() - .setMode(IrisStructureTerrainMode.FORCE_CARVE) + .setMode(terrainMode) .setHorizontalPadding(horizontalPadding); IrisStructurePlacement placement = new IrisStructurePlacement() .setPlacementId(placementId) @@ -182,4 +296,19 @@ public class NativeStructureOwnershipRecoveryTest { monumentStart(structure(), origin, 1L)).minY() ); } + + private static boolean hasExpandedCoverage( + NativeStructureOwnershipRecord stale, + NativeStructureOwnershipRecord refreshed) { + for (int chunkX = refreshed.referenceMinChunkX(); + chunkX <= refreshed.referenceMaxChunkX(); chunkX++) { + for (int chunkZ = refreshed.referenceMinChunkZ(); + chunkZ <= refreshed.referenceMaxChunkZ(); chunkZ++) { + if (!stale.covers(chunkX, chunkZ)) { + return true; + } + } + } + return false; + } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java index f937ea053..605a210d3 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorSurfaceTerrainTest.java @@ -6,14 +6,18 @@ import art.arcane.iris.engine.object.IrisStructureTerrain; import art.arcane.iris.engine.object.IrisStructureTerrainMode; import com.mojang.datafixers.util.Either; import com.mojang.serialization.Codec; +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.IdMapper; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; import net.minecraft.server.Bootstrap; import net.minecraft.util.RandomSource; import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelReader; import net.minecraft.world.level.LevelHeightAccessor; import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.biome.Biome; @@ -36,13 +40,18 @@ import net.minecraft.world.level.levelgen.structure.StructurePiece; import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; +import net.minecraft.world.level.levelgen.structure.pools.LegacySinglePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement; import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement; import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.BlockIgnoreProcessor; import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece; import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure; import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorList; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessor; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import org.junit.BeforeClass; @@ -55,8 +64,11 @@ import java.lang.reflect.Modifier; import java.lang.reflect.Proxy; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -118,6 +130,34 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { new IrisStructureTerrain().setMode(IrisStructureTerrainMode.PRESERVE)))); } + @Test + public void explicitVacuumForcesThinSurfaceFittingWithoutAuthoredAdaptation() { + StructureStart none = desertStart(TerrainAdjustment.NONE); + StructureStart box = desertStart(TerrainAdjustment.BEARD_BOX); + NativeStructureTerrainIntegrator.TerrainTarget sourceNone = + new NativeStructureTerrainIntegrator.TerrainTarget( + "test:none", none, + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE)); + NativeStructureTerrainIntegrator.TerrainTarget vacuumNone = + new NativeStructureTerrainIntegrator.TerrainTarget( + "test:none", none, + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.VACUUM)); + NativeStructureTerrainIntegrator.TerrainTarget vacuumBox = + new NativeStructureTerrainIntegrator.TerrainTarget( + "test:box", box, + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.VACUUM)); + + assertFalse(NativeStructureSurfaceFitter.requiresSurfaceTerrain(sourceNone)); + assertTrue(NativeStructureSurfaceFitter.requiresSurfaceTerrain(vacuumNone)); + assertTrue(NativeStructureSurfaceFitter.requiresSurfaceTerrain(vacuumBox)); + assertEquals(TerrainAdjustment.BEARD_THIN, + NativeStructureSurfaceFitter.effectiveSurfaceAdjustment(vacuumNone)); + assertEquals(TerrainAdjustment.BEARD_THIN, + NativeStructureSurfaceFitter.effectiveSurfaceAdjustment(vacuumBox)); + assertTrue(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir( + none, vacuumNone.terrain())); + } + @Test public void beardBoxUsesTheFullRigidHeightWhileBeardThinUsesTheGroundPlane() { NativeStructureSurfaceFitter.SurfaceAnchor thin = @@ -210,11 +250,12 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { } } - NativeStructureSurfaceFitter.prepareSurfaceStructures( - world(blocks), area, - List.of(new NativeStructureTerrainIntegrator.TerrainTarget( + List targets = List.of( + new NativeStructureTerrainIntegrator.TerrainTarget( "nova_structures:tavern_oak", start, - new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))), + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))); + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, targets, (x, z) -> 64); assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 1, 71, 1)); @@ -263,6 +304,392 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { List.of(strongTie, weakTie), 2, 2, 64)); } + @Test + public void postClearSupportUsesUpperOccupancyOverLowerLegacyAir() throws Exception { + StructureTemplate lowerTemplate = template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState()), + block(1, 0, 0, Blocks.AIR.defaultBlockState()))); + StructureTemplate upperTemplate = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()), + block(1, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece lower = rigidTemplatePiece( + new InlineLegacyPoolElement(lowerTemplate), + new BoundingBox(0, 62, 0, 1, 65, 0), 1, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + new InlineLegacyPoolElement(upperTemplate), + new BoundingBox(0, 64, 0, 1, 70, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(lower, upper)); + BoundingBox area = new BoundingBox(0, 58, 0, 1, 72, 0); + Map blocks = new HashMap<>(); + for (int x = 0; x <= 1; x++) { + put(blocks, x, 61, 0, Blocks.DIRT.defaultBlockState()); + put(blocks, x, 62, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + } + put(blocks, 1, 63, 0, Blocks.DIRT.defaultBlockState()); + + Set written = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(blocks), area, List.of(surfaceTarget(start)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + + assertEquals(Set.of(BlockPos.asLong(0, 63, 0)), written); + assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 0, 63, 0)); + assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 1, 63, 0)); + } + + @Test + public void rotatedLowestAuthoredVoidFluidAirAndJigsawColumnsRemainOpen() throws Exception { + List upperBlocks = new ArrayList<>(); + upperBlocks.add(block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())); + upperBlocks.add(block(1, 0, 0, Blocks.AIR.defaultBlockState())); + upperBlocks.add(block(2, 0, 0, Blocks.STRUCTURE_VOID.defaultBlockState())); + upperBlocks.add(block(3, 0, 0, Blocks.WATER.defaultBlockState())); + upperBlocks.add(block(4, 0, 0, Blocks.JIGSAW.defaultBlockState())); + for (int x = 1; x <= 4; x++) { + upperBlocks.add(block(x, 1, 0, Blocks.STONE.defaultBlockState())); + } + StructureTemplate upperTemplate = template(upperBlocks); + PoolElementStructurePiece lower = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(10, 62, 10, 10, 65, 14), 1, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + new InlineSinglePoolElement(upperTemplate), + new BoundingBox(10, 64, 10, 10, 70, 14), 1, + Rotation.CLOCKWISE_90); + StructureStart start = rigidSurfaceStart(List.of(lower, upper)); + BoundingBox area = new BoundingBox(10, 58, 10, 10, 72, 14); + Map blocks = new HashMap<>(); + for (int z = 10; z <= 14; z++) { + put(blocks, 10, 61, z, Blocks.DIRT.defaultBlockState()); + put(blocks, 10, 62, z, Blocks.GRASS_BLOCK.defaultBlockState()); + } + + Set written = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(blocks), area, List.of(surfaceTarget(start)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + + StructurePlaceSettings settings = new StructurePlaceSettings() + .setRotation(Rotation.CLOCKWISE_90); + BlockPos origin = upper.getPosition(); + BlockPos supportedBase = origin.offset( + StructureTemplate.calculateRelativePosition(settings, BlockPos.ZERO)); + assertEquals(Set.of(supportedBase.below().asLong()), written); + assertEquals(Blocks.DIRT.defaultBlockState(), state( + blocks, supportedBase.getX(), supportedBase.getY() - 1, supportedBase.getZ())); + for (int localX = 1; localX <= 4; localX++) { + BlockPos vetoedBase = origin.offset(StructureTemplate.calculateRelativePosition( + settings, new BlockPos(localX, 0, 0))); + assertEquals(Blocks.AIR.defaultBlockState(), state( + blocks, vetoedBase.getX(), vetoedBase.getY() - 1, vetoedBase.getZ())); + } + } + + @Test + public void listElementsUseDeclaredOverlayOrderForLowestAuthoredCells() throws Exception { + InlineLegacyPoolElement legacy = new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()), + block(1, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))); + InlineSinglePoolElement single = new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState()), + block(1, 0, 0, Blocks.STONE.defaultBlockState())))); + ListPoolElement list = new ListPoolElement( + List.of(legacy, single), StructureTemplatePool.Projection.RIGID); + PoolElementStructurePiece lower = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(0, 62, 0, 1, 65, 0), 1, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + list, new BoundingBox(0, 64, 0, 1, 70, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(lower, upper)); + BoundingBox area = new BoundingBox(0, 58, 0, 1, 72, 0); + Map blocks = new HashMap<>(); + for (int x = 0; x <= 1; x++) { + put(blocks, x, 61, 0, Blocks.DIRT.defaultBlockState()); + put(blocks, x, 62, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + } + + Set written = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(blocks), area, List.of(surfaceTarget(start)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + + assertEquals(Set.of(BlockPos.asLong(1, 63, 0)), written); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 0, 63, 0)); + assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 1, 63, 0)); + } + + @Test + public void surfaceSupportIsChunkClippedAndStableAcrossSplitAreas() throws Exception { + StructureTemplate upperTemplate = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()), + block(1, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece lower = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(15, 62, 0, 16, 65, 0), 1, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + new InlineSinglePoolElement(upperTemplate), + new BoundingBox(15, 64, 0, 16, 70, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(lower, upper)); + BoundingBox wideArea = new BoundingBox(0, 58, 0, 31, 72, 15); + BoundingBox westArea = new BoundingBox(0, 58, 0, 15, 72, 15); + BoundingBox eastArea = new BoundingBox(16, 58, 0, 31, 72, 15); + Map wideBlocks = supportTerrain(15, 16); + Map splitBlocks = supportTerrain(15, 16); + + Set wide = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(wideBlocks), wideArea, List.of(surfaceTarget(start)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + Set split = new HashSet<>( + NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(splitBlocks), westArea, List.of(surfaceTarget(start)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager)); + assertEquals(Blocks.AIR.defaultBlockState(), state(splitBlocks, 16, 63, 0)); + split.addAll(NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(splitBlocks), eastArea, List.of(surfaceTarget(start)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager)); + + assertEquals(Set.of( + BlockPos.asLong(15, 63, 0), BlockPos.asLong(16, 63, 0)), wide); + assertEquals(wide, split); + assertEquals(wideBlocks, splitBlocks); + } + + @Test + public void supportNeverPairsRigidAnchorsAcrossStarts() throws Exception { + PoolElementStructurePiece lower = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(0, 62, 0, 0, 65, 0), 1, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 64, 0, 0, 70, 0), 1, Rotation.NONE); + StructureStart lowerStart = rigidSurfaceStart(List.of(lower)); + StructureStart upperStart = rigidSurfaceStart(List.of(upper)); + BoundingBox area = new BoundingBox(0, 58, 0, 0, 72, 0); + Map blocks = supportTerrain(0, 0); + + assertTrue(NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(blocks), area, List.of(surfaceTarget(lowerStart)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager).isEmpty()); + assertTrue(NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(blocks), area, List.of(surfaceTarget(upperStart)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager).isEmpty()); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 0, 63, 0)); + } + + @Test + public void vacuumSupportsUnauthoredTerrainModesButLongMeetGapsRemainOpen() throws Exception { + StructureTemplate upperTemplate = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece lower = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(0, 62, 0, 0, 65, 0), 1, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + new InlineSinglePoolElement(upperTemplate), + new BoundingBox(0, 64, 0, 0, 70, 0), 1, Rotation.NONE); + StructureStart vacuumStart = rigidSurfaceStart( + List.of(lower, upper), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 58, 0, 0, 72, 0); + Map vacuumBlocks = supportTerrain(0, 0); + + Set vacuumWritten = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(vacuumBlocks), area, + List.of(surfaceTarget(vacuumStart, IrisStructureTerrainMode.VACUUM)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + + assertEquals(Set.of(BlockPos.asLong(0, 63, 0)), vacuumWritten); + assertTrue(NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(supportTerrain(0, 0)), area, + List.of(surfaceTarget(vacuumStart, IrisStructureTerrainMode.SOURCE)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager).isEmpty()); + + PoolElementStructurePiece distantUpper = rigidTemplatePiece( + new InlineSinglePoolElement(upperTemplate), + new BoundingBox(0, 65, 0, 0, 71, 0), 1, Rotation.NONE); + StructureStart distantStart = rigidSurfaceStart( + List.of(lower, distantUpper), TerrainAdjustment.NONE); + assertTrue(NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(supportTerrain(0, 0)), area, + List.of(surfaceTarget(distantStart, IrisStructureTerrainMode.VACUUM)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager).isEmpty()); + } + + @Test + public void threeRigidPlanesCannotBuildAnUpwardSupportChain() throws Exception { + PoolElementStructurePiece lower = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(0, 62, 0, 0, 65, 0), 1, Rotation.NONE); + PoolElementStructurePiece middle = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 64, 0, 0, 69, 0), 0, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.STONE.defaultBlockState())))), + new BoundingBox(0, 65, 0, 0, 70, 0), 0, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(lower, middle, upper)); + BoundingBox area = new BoundingBox(0, 58, 0, 0, 72, 0); + Map blocks = supportTerrain(0, 0); + + Set written = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(blocks), area, List.of(surfaceTarget(start)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + + assertEquals(Set.of(BlockPos.asLong(0, 63, 0)), written); + assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 0, 63, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 0, 64, 0)); + } + + @Test + public void reversingTargetsCannotCreateCrossStartSupportChains() throws Exception { + PoolElementStructurePiece ground = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(0, 62, 0, 0, 65, 0), 1, Rotation.NONE); + PoolElementStructurePiece middle = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 64, 0, 0, 69, 0), 0, Rotation.NONE); + PoolElementStructurePiece raisedGround = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(0, 64, 0, 0, 69, 0), 0, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.STONE.defaultBlockState())))), + new BoundingBox(0, 65, 0, 0, 70, 0), 0, Rotation.NONE); + StructureStart lowerStart = rigidSurfaceStart(List.of(ground, middle)); + StructureStart upperStart = rigidSurfaceStart(List.of(raisedGround, upper)); + BoundingBox area = new BoundingBox(0, 58, 0, 0, 72, 0); + Map forwardBlocks = supportTerrain(0, 0); + Map reverseBlocks = supportTerrain(0, 0); + NativeStructureTerrainIntegrator.TerrainTarget lowerTarget = surfaceTarget(lowerStart); + NativeStructureTerrainIntegrator.TerrainTarget upperTarget = surfaceTarget(upperStart); + + Set forward = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(forwardBlocks), area, List.of(lowerTarget, upperTarget), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + Set reverse = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(reverseBlocks), area, List.of(upperTarget, lowerTarget), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + + assertEquals(Set.of(BlockPos.asLong(0, 63, 0)), forward); + assertEquals(forward, reverse); + assertEquals(forwardBlocks, reverseBlocks); + assertEquals(Blocks.AIR.defaultBlockState(), state(forwardBlocks, 0, 64, 0)); + } + + @Test + public void preserveDuplicateCannotSuppressVacuumSupport() throws Exception { + PoolElementStructurePiece lower = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(0, 62, 0, 0, 65, 0), 1, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 64, 0, 0, 70, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart( + List.of(lower, upper), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 58, 0, 0, 72, 0); + Map blocks = supportTerrain(0, 0); + + Set written = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(blocks), area, List.of( + surfaceTarget(start, IrisStructureTerrainMode.PRESERVE), + surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + + assertEquals(Set.of(BlockPos.asLong(0, 63, 0)), written); + } + + @Test + public void processorCreatedAirAndFluidVetoSurfaceSupport() throws Exception { + StructureTemplate upperTemplate = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()), + block(1, 0, 0, Blocks.STONE.defaultBlockState()))); + InlineSinglePoolElement upperElement = new InlineSinglePoolElement( + upperTemplate, List.of( + new ReplaceBlockProcessor( + Blocks.COBBLESTONE.defaultBlockState(), + Blocks.AIR.defaultBlockState()), + new ReplaceBlockProcessor( + Blocks.STONE.defaultBlockState(), + Blocks.WATER.defaultBlockState()))); + PoolElementStructurePiece lower = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(0, 62, 0, 1, 65, 0), 1, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + upperElement, new BoundingBox(0, 64, 0, 1, 70, 0), + 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(lower, upper)); + BoundingBox area = new BoundingBox(0, 58, 0, 1, 72, 0); + Map blocks = supportTerrain(0, 1); + + Set written = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(blocks), area, List.of(surfaceTarget(start)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + + assertTrue(written.isEmpty()); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 0, 63, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 1, 63, 0)); + } + + @Test + public void solidJigsawFinalStateCreatesSurfaceSupport() throws Exception { + CompoundTag jigsawData = new CompoundTag(); + jigsawData.putString("final_state", "minecraft:cobblestone"); + StructureTemplate upperTemplate = template(List.of( + new StructureTemplate.StructureBlockInfo( + BlockPos.ZERO, Blocks.JIGSAW.defaultBlockState(), jigsawData))); + PoolElementStructurePiece lower = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(0, 62, 0, 0, 65, 0), 1, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + new InlineSinglePoolElement(upperTemplate), + new BoundingBox(0, 64, 0, 0, 70, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(lower, upper)); + BoundingBox area = new BoundingBox(0, 58, 0, 0, 72, 0); + Map blocks = supportTerrain(0, 0); + + Set written = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(blocks), area, List.of(surfaceTarget(start)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + + assertEquals(Set.of(BlockPos.asLong(0, 63, 0)), written); + assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 0, 63, 0)); + } + + @Test + public void laterLegacyAirDoesNotOverlayEarlierListSolid() throws Exception { + InlineSinglePoolElement solid = new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))); + InlineLegacyPoolElement legacyAir = new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))); + ListPoolElement list = new ListPoolElement( + List.of(solid, legacyAir), StructureTemplatePool.Projection.RIGID); + PoolElementStructurePiece lower = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(0, 62, 0, 0, 65, 0), 1, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + list, new BoundingBox(0, 64, 0, 0, 70, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(lower, upper)); + BoundingBox area = new BoundingBox(0, 58, 0, 0, 72, 0); + Map blocks = supportTerrain(0, 0); + + Set written = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world(blocks), area, List.of(surfaceTarget(start)), + NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager); + + assertEquals(Set.of(BlockPos.asLong(0, 63, 0)), written); + } + @Test public void stackedRigidPiecesPreserveTheLowerAuthoredSurface() { Structure structure = new DesertPyramidStructure( @@ -286,12 +713,11 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { } } + WorldGenLevel world = world(blocks); + List targets = + List.of(surfaceTarget(start)); NativeStructureSurfaceFitter.prepareSurfaceStructures( - world(blocks), area, - List.of(new NativeStructureTerrainIntegrator.TerrainTarget( - "nova_structures:tavern_oak", start, - new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))), - (x, z) -> 60); + world, area, targets, (x, z) -> 60); assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 2, 62, 2)); assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 2, 65, 2)); @@ -519,7 +945,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { } @Test - public void nativeVacuumClearsEveryPieceEnvelopeBeforePlacement() { + public void nativeVacuumLeavesPieceBlocksForSurfaceFitting() { StructureStart start = desertStart(); BoundingBox bounds = start.getPieces().getFirst().getBoundingBox(); Map blocks = new HashMap<>(); @@ -529,7 +955,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { world(blocks), bounds, "minecraft:desert_pyramid", start, new IrisStructureTerrain().setMode(IrisStructureTerrainMode.VACUUM), null); - assertEquals(Blocks.AIR.defaultBlockState(), + assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, bounds.minX(), bounds.minY(), bounds.minZ())); } @@ -892,6 +1318,27 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { assertEquals(generated.getPieces().size(), wrapped.getPieces().size()); } + @Test + public void nativeVacuumReservesItsFixedSurfaceFalloff() { + StructureStart generated = desertStart(TerrainAdjustment.NONE); + BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(generated); + IrisStructureTerrain terrain = new IrisStructureTerrain() + .setMode(IrisStructureTerrainMode.VACUUM) + .setHorizontalPadding(64); + + BoundingBox references = NativeStructureReferenceEnvelope.referenceBounds( + generated, generated.getStructure(), terrain); + + assertEquals(content.minX() - NativeStructureSurfaceFitter.surfaceTerrainRadius(), + references.minX()); + assertEquals(content.maxX() + NativeStructureSurfaceFitter.surfaceTerrainRadius(), + references.maxX()); + assertEquals(content.minZ() - NativeStructureSurfaceFitter.surfaceTerrainRadius(), + references.minZ()); + assertEquals(content.maxZ() + NativeStructureSurfaceFitter.surfaceTerrainRadius(), + references.maxZ()); + } + @Test public void nativeTerrainEnvelopeClipsOptionalCoverageWithoutDroppingContent() { StructureStart generated = desertStart(); @@ -999,7 +1446,8 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { BlockState log = Blocks.OAK_LOG.defaultBlockState(); blocks.put(origin, log); - NativeStructureTerrainIntegrator.clearTemplateAir(world(blocks), template, origin, 80, settings); + NativeStructureTerrainIntegrator.clearTemplateAir( + world(blocks), template, origin, 80, settings); assertEquals(log, blocks.get(origin)); } @@ -1018,6 +1466,71 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { LiquidSettings.APPLY_WATERLOGGING); } + private static PoolElementStructurePiece rigidTemplatePiece( + StructurePoolElement element, BoundingBox bounds, + int groundLevelDelta, Rotation rotation) { + return new PoolElementStructurePiece( + null, element, + new BlockPos(bounds.minX(), bounds.minY(), bounds.minZ()), + groundLevelDelta, rotation, bounds, + LiquidSettings.APPLY_WATERLOGGING); + } + + private static StructureStart rigidSurfaceStart(List pieces) { + return rigidSurfaceStart(pieces, TerrainAdjustment.BEARD_BOX); + } + + private static StructureStart rigidSurfaceStart( + List pieces, TerrainAdjustment adjustment) { + Structure structure = new DesertPyramidStructure( + new Structure.StructureSettings( + HolderSet.empty(), Map.of(), + GenerationStep.Decoration.SURFACE_STRUCTURES, + adjustment)); + List structurePieces = new ArrayList<>(pieces); + return new StructureStart( + structure, new ChunkPos(0, 0), 0, + new PiecesContainer(structurePieces)); + } + + private static NativeStructureTerrainIntegrator.TerrainTarget surfaceTarget( + StructureStart start) { + return surfaceTarget(start, IrisStructureTerrainMode.SOURCE); + } + + private static NativeStructureTerrainIntegrator.TerrainTarget surfaceTarget( + StructureStart start, IrisStructureTerrainMode mode) { + return new NativeStructureTerrainIntegrator.TerrainTarget( + "nova_structures:tavern_oak", start, + new IrisStructureTerrain().setMode(mode)); + } + + private static StructureTemplate.StructureBlockInfo block( + int x, int y, int z, BlockState state) { + return new StructureTemplate.StructureBlockInfo( + new BlockPos(x, y, z), state, null); + } + + private static Map supportTerrain(int minimumX, int maximumX) { + Map blocks = new HashMap<>(); + for (int x = minimumX; x <= maximumX; x++) { + put(blocks, x, 61, 0, Blocks.DIRT.defaultBlockState()); + put(blocks, x, 62, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + } + return blocks; + } + + private static Map flatTerrain(BoundingBox area, int surfaceY) { + Map blocks = new HashMap<>(); + for (int x = area.minX(); x <= area.maxX(); x++) { + for (int z = area.minZ(); z <= area.maxZ(); z++) { + put(blocks, x, surfaceY - 1, z, Blocks.DIRT.defaultBlockState()); + put(blocks, x, surfaceY, z, Blocks.GRASS_BLOCK.defaultBlockState()); + } + } + return blocks; + } + private static NativeStructureTerrainIntegrator.OrganicCarve organicCarve( StructureStart start, int horizontalPadding) { return organicCarve(start, horizontalPadding, 0.85D); @@ -1122,6 +1635,57 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { return template; } + private static final class InlineSinglePoolElement extends SinglePoolElement { + private InlineSinglePoolElement(StructureTemplate template) { + this(template, List.of()); + } + + private InlineSinglePoolElement( + StructureTemplate template, List processors) { + super(Either.right(template), + Holder.direct(new StructureProcessorList(processors)), + StructureTemplatePool.Projection.RIGID, + Optional.empty()); + } + } + + private static final class InlineLegacyPoolElement extends LegacySinglePoolElement { + private InlineLegacyPoolElement(StructureTemplate template) { + super(Either.right(template), + Holder.direct(new StructureProcessorList(List.of())), + StructureTemplatePool.Projection.RIGID, + Optional.empty()); + } + } + + private static final class ReplaceBlockProcessor implements StructureProcessor { + private final BlockState source; + private final BlockState replacement; + + private ReplaceBlockProcessor(BlockState source, BlockState replacement) { + this.source = source; + this.replacement = replacement; + } + + @Override + public StructureTemplate.StructureBlockInfo processBlock( + LevelReader level, BlockPos targetPosition, BlockPos referencePos, + BlockPos templateRelativePos, + StructureTemplate.StructureBlockInfo processedBlockInfo, + StructurePlaceSettings settings) { + if (!processedBlockInfo.state().equals(source)) { + return processedBlockInfo; + } + return new StructureTemplate.StructureBlockInfo( + processedBlockInfo.pos(), replacement, processedBlockInfo.nbt()); + } + + @Override + public MapCodec codec() { + return BlockIgnoreProcessor.STRUCTURE_BLOCK.codec(); + } + } + private static StructureStart desertStart() { return desertStart(TerrainAdjustment.NONE); } @@ -1155,6 +1719,9 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { if (methodName.equals("getLevel")) { return null; } + if (methodName.equals("holderLookup")) { + return BuiltInRegistries.BLOCK; + } if (methodName.equals("hashCode")) { return System.identityHashCode(proxy); } @@ -1187,6 +1754,9 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { if (methodName.equals("getLevel")) { return null; } + if (methodName.equals("holderLookup")) { + return BuiltInRegistries.BLOCK; + } if (methodName.equals("hashCode")) { return System.identityHashCode(proxy); } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecovery.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecovery.java index 3e8b99a69..70c756c7d 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecovery.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureOwnershipRecovery.java @@ -7,6 +7,8 @@ import art.arcane.iris.engine.framework.NativeStructureOwnershipStore; import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner; import art.arcane.iris.engine.framework.NativeStructureStartPlan; import art.arcane.iris.engine.framework.StructurePlacementGrid; +import art.arcane.iris.engine.object.IrisStructureTerrain; +import art.arcane.iris.engine.object.IrisStructureTerrainMode; import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.engine.object.NativeStructureSuppression; import net.minecraft.core.Holder; @@ -44,8 +46,13 @@ public final class NativeStructureOwnershipRecovery { NativeStructureOwnershipRecord persisted = NativeStructureOwnershipStore.findPersisted( engine, structureKey, origin.x(), origin.z()); if (persisted != null) { - if (NativeStructureOwnershipFingerprint.matches(persisted, start)) { - return persisted; + NativeStructureOwnershipRecord refreshed = refreshReferenceEnvelope( + structureKey, activeStructure, start, persisted); + if (refreshed != null) { + if (refreshed != persisted) { + NativeStructureOwnershipStore.record(engine, refreshed); + } + return refreshed; } NativeStructureOwnershipStore.discard( engine, structureKey, origin.x(), origin.z()); @@ -72,6 +79,37 @@ public final class NativeStructureOwnershipRecovery { return recovered; } + static NativeStructureOwnershipRecord refreshReferenceEnvelope( + String structureKey, Structure structure, StructureStart start, + NativeStructureOwnershipRecord ownership) { + if (structure == null || start == null || !start.isValid() + || start.getStructure() != structure || ownership == null + || !ownership.structureKey().equals(normalize(structureKey)) + || !NativeStructureOwnershipFingerprint.matches(ownership, start)) { + return null; + } + IrisStructureTerrain terrain = NativeStructureTerrainIntegrator.resolveNativeTerrain( + start, ownership.restoredDecision().terrain()); + if (terrain.resolvedMode() != IrisStructureTerrainMode.VACUUM) { + return ownership; + } + BoundingBox expected = NativeStructureReferenceEnvelope.referenceBounds( + start, structure, terrain, structureKey); + int referenceMinChunkX = expected.minX() >> 4; + int referenceMaxChunkX = expected.maxX() >> 4; + int referenceMinChunkZ = expected.minZ() >> 4; + int referenceMaxChunkZ = expected.maxZ() >> 4; + if (ownership.referenceMinChunkX() == referenceMinChunkX + && ownership.referenceMaxChunkX() == referenceMaxChunkX + && ownership.referenceMinChunkZ() == referenceMinChunkZ + && ownership.referenceMaxChunkZ() == referenceMaxChunkZ) { + return ownership; + } + return ownership.withReferenceEnvelope( + referenceMinChunkX, referenceMaxChunkX, + referenceMinChunkZ, referenceMaxChunkZ); + } + static NativeStructureOwnershipRecord proveCandidate( String structureKey, Structure structure, StructureStart persisted, NativeStructureStartPlan plan, StructureStart expected, diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java index 962cffea8..6996e36fe 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java @@ -55,6 +55,8 @@ public final class NativeStructurePostProcessor { world, area, target.start(), () -> world.getLevel().getStructureManager()); } } + NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport( + world, area, targets, () -> world.getLevel().getStructureManager()); } @FunctionalInterface diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceEnvelope.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceEnvelope.java index a5b8757cd..1c0bb0b97 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceEnvelope.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReferenceEnvelope.java @@ -64,7 +64,9 @@ public final class NativeStructureReferenceEnvelope { || mode == IrisStructureTerrainMode.FORCE_CARVE || mode == IrisStructureTerrainMode.VACUUM || mode == IrisStructureTerrainMode.ENCASE; - int horizontalPadding = usesEnvelope ? Math.max(0, terrain.getHorizontalPadding()) : 0; + int horizontalPadding = mode == IrisStructureTerrainMode.VACUUM + ? NativeStructureSurfaceFitter.surfaceTerrainRadius() + : usesEnvelope ? Math.max(0, terrain.getHorizontalPadding()) : 0; BoundingBox content = contentBounds(start); if (!fitsReferenceRange(start.getChunkPos(), content)) { throw new UnrepresentableContentException("Native structure content at " diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReflection.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReflection.java index 2b78953f4..8a7a41c3d 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReflection.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureReflection.java @@ -1,15 +1,27 @@ package art.arcane.iris.nativegen; import com.mojang.datafixers.util.Either; +import net.minecraft.core.BlockPos; import net.minecraft.resources.Identifier; +import net.minecraft.world.level.ServerLevelAccessor; +import net.minecraft.world.level.block.Rotation; import net.minecraft.world.level.levelgen.structure.ScatteredFeaturePiece; +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.templatesystem.LiquidSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.function.Supplier; @@ -100,6 +112,63 @@ final class NativeStructureReflection { return resolveTemplateReference(reference, templates); } + @SuppressWarnings("unchecked") + static List resolveTemplateBlocks( + StructureTemplate template, StructurePlaceSettings settings, + BlockPos origin) { + Object value; + try { + value = StructureTemplatePalettesAccess.FIELD.get(template); + } catch (IllegalAccessException error) { + throw new IllegalStateException("Cannot read native structure template palettes", error); + } + if (!(value instanceof List paletteValues)) { + throw new IllegalStateException("Native structure template palettes field is not a List"); + } + List palettes = + (List) paletteValues; + if (palettes.isEmpty()) { + return List.of(); + } + return settings.getRandomPalette(palettes, origin).blocks(); + } + + static StructurePlaceSettings resolvePlacementSettings( + SinglePoolElement element, PoolElementStructurePiece piece, + BoundingBox area) { + LiquidSettings liquidSettings; + try { + liquidSettings = (LiquidSettings) PoolPieceLiquidSettingsAccess.FIELD.get(piece); + } catch (IllegalAccessException error) { + throw new IllegalStateException("Cannot read native structure piece liquid settings", error); + } + try { + return (StructurePlaceSettings) SinglePoolSettingsAccess.METHOD.invoke( + element, piece.getRotation(), area, liquidSettings, false); + } catch (IllegalAccessException | InvocationTargetException error) { + throw new IllegalStateException("Cannot create native structure placement settings", error); + } + } + + @SuppressWarnings("unchecked") + static List processTemplateBlocks( + ServerLevelAccessor world, BlockPos origin, BlockPos referencePos, + StructurePlaceSettings settings, + List blocks, + StructureTemplate template) { + Method forgeMethod = StructureTemplateProcessingAccess.FORGE_METHOD; + if (forgeMethod == null) { + return StructureTemplate.processBlockInfos( + world, origin, referencePos, settings, blocks); + } + try { + return (List) forgeMethod.invoke( + null, world, origin, referencePos, settings, blocks, template); + } catch (IllegalAccessException | InvocationTargetException error) { + throw new IllegalStateException("Cannot process native structure template blocks", error); + } + } + static StructureTemplate resolveTemplateReference(Either reference, Supplier templates) { return reference.map( @@ -125,6 +194,113 @@ final class NativeStructureReflection { return template; } + private static Field resolveStructureTemplatePalettesField() { + Field resolved = null; + for (Field field : StructureTemplate.class.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.getType() != List.class + || !isPaletteList(field.getGenericType())) { + continue; + } + if (resolved != null) { + throw new IllegalStateException("StructureTemplate has multiple palette List fields"); + } + resolved = field; + } + if (resolved == null) { + throw new IllegalStateException("StructureTemplate palette List field is missing"); + } + if (!Modifier.isFinal(resolved.getModifiers())) { + throw new IllegalStateException("StructureTemplate palette field has an unexpected access contract"); + } + if (!resolved.trySetAccessible()) { + throw new IllegalStateException("StructureTemplate palette field is inaccessible"); + } + return resolved; + } + + private static boolean isPaletteList(Type type) { + if (!(type instanceof ParameterizedType parameterized)) { + return false; + } + Type[] arguments = parameterized.getActualTypeArguments(); + return arguments.length == 1 && arguments[0] == StructureTemplate.Palette.class; + } + + private static Method resolveSinglePoolSettingsMethod() { + Method resolved = null; + Class[] parameterTypes = { + Rotation.class, BoundingBox.class, LiquidSettings.class, boolean.class + }; + for (Method method : SinglePoolElement.class.getDeclaredMethods()) { + if (Modifier.isStatic(method.getModifiers()) + || method.getReturnType() != StructurePlaceSettings.class + || !Arrays.equals(method.getParameterTypes(), parameterTypes)) { + continue; + } + if (resolved != null) { + throw new IllegalStateException("SinglePoolElement has multiple placement-settings methods"); + } + resolved = method; + } + if (resolved == null) { + throw new IllegalStateException("SinglePoolElement placement-settings method is missing"); + } + if (!Modifier.isProtected(resolved.getModifiers())) { + throw new IllegalStateException("SinglePoolElement placement-settings method has an unexpected access contract"); + } + if (!resolved.trySetAccessible()) { + throw new IllegalStateException("SinglePoolElement placement-settings method is inaccessible"); + } + return resolved; + } + + private static Field resolvePoolPieceLiquidSettingsField() { + Field resolved = null; + for (Field field : PoolElementStructurePiece.class.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) + || field.getType() != LiquidSettings.class) { + continue; + } + if (resolved != null) { + throw new IllegalStateException("PoolElementStructurePiece has multiple liquid-settings fields"); + } + resolved = field; + } + if (resolved == null) { + throw new IllegalStateException("PoolElementStructurePiece liquid-settings field is missing"); + } + if (!Modifier.isPrivate(resolved.getModifiers()) + || !Modifier.isFinal(resolved.getModifiers())) { + throw new IllegalStateException("PoolElementStructurePiece liquid-settings field has an unexpected access contract"); + } + if (!resolved.trySetAccessible()) { + throw new IllegalStateException("PoolElementStructurePiece liquid-settings field is inaccessible"); + } + return resolved; + } + + private static Method resolveForgeTemplateProcessingMethod() { + Class[] parameterTypes = { + ServerLevelAccessor.class, BlockPos.class, BlockPos.class, + StructurePlaceSettings.class, List.class, StructureTemplate.class + }; + Method resolved = null; + for (Method method : StructureTemplate.class.getDeclaredMethods()) { + if (!Modifier.isPublic(method.getModifiers()) + || !Modifier.isStatic(method.getModifiers()) + || method.getReturnType() != List.class + || !Arrays.equals(method.getParameterTypes(), parameterTypes)) { + continue; + } + if (resolved != null) { + throw new IllegalStateException( + "StructureTemplate has multiple Forge block-processing methods"); + } + resolved = method; + } + return resolved; + } + static final class MonumentChildPiecesAccess { static final Field FIELD = resolveMonumentChildPiecesField(); @@ -145,4 +321,32 @@ final class NativeStructureReflection { private SinglePoolTemplateAccess() { } } + + private static final class StructureTemplatePalettesAccess { + private static final Field FIELD = resolveStructureTemplatePalettesField(); + + private StructureTemplatePalettesAccess() { + } + } + + private static final class SinglePoolSettingsAccess { + private static final Method METHOD = resolveSinglePoolSettingsMethod(); + + private SinglePoolSettingsAccess() { + } + } + + private static final class PoolPieceLiquidSettingsAccess { + private static final Field FIELD = resolvePoolPieceLiquidSettingsField(); + + private PoolPieceLiquidSettingsAccess() { + } + } + + private static final class StructureTemplateProcessingAccess { + private static final Method FORGE_METHOD = resolveForgeTemplateProcessingMethod(); + + private StructureTemplateProcessingAccess() { + } + } } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java index ddcf39004..1fe35ab30 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceFitter.java @@ -48,6 +48,10 @@ public final class NativeStructureSurfaceFitter { || adjustment == TerrainAdjustment.BEARD_BOX; } + static int surfaceTerrainRadius() { + return SURFACE_TERRAIN_RADIUS; + } + static int resolveSurfaceTarget(List anchors, int worldX, int worldZ, int originalY) { return resolveSurface(anchors, worldX, worldZ, originalY).targetY(); @@ -160,7 +164,7 @@ public final class NativeStructureSurfaceFitter { continue; } StructureStart start = target.start(); - TerrainAdjustment adjustment = start.getStructure().terrainAdaptation(); + TerrainAdjustment adjustment = effectiveSurfaceAdjustment(target); for (StructurePiece piece : start.getPieces()) { if (piece instanceof PoolElementStructurePiece poolPiece) { if (poolPiece.getElement().getProjection() == StructureTemplatePool.Projection.RIGID) { @@ -203,17 +207,28 @@ public final class NativeStructureSurfaceFitter { } static boolean requiresSurfaceTerrain(NativeStructureTerrainIntegrator.TerrainTarget target) { - if (target == null || target.terrain() == null - || target.terrain().resolvedMode() != IrisStructureTerrainMode.SOURCE) { + if (target == null || target.terrain() == null) { return false; } StructureStart start = target.start(); - return start != null - && start.isValid() + if (start == null || !start.isValid()) { + return false; + } + IrisStructureTerrainMode mode = target.terrain().resolvedMode(); + return mode == IrisStructureTerrainMode.VACUUM + || mode == IrisStructureTerrainMode.SOURCE && shouldPrepareSurfaceTerrain( start.getStructure().terrainAdaptation(), start.getStructure().step()); } + static TerrainAdjustment effectiveSurfaceAdjustment( + NativeStructureTerrainIntegrator.TerrainTarget target) { + if (target.terrain().resolvedMode() == IrisStructureTerrainMode.VACUUM) { + return TerrainAdjustment.BEARD_THIN; + } + return target.start().getStructure().terrainAdaptation(); + } + private static void fitSurfaceTerrain(WorldGenLevel world, BoundingBox area, List anchors, IntBinaryOperator surfaceHeight) { @@ -382,6 +397,7 @@ public final class NativeStructureSurfaceFitter { int verticalDistance(int y) { return IrisObjectVacuum.outset(y, minInfluenceY, maxInfluenceY); } + } private record SurfaceMaterials(BlockState surface, BlockState subsurface) { diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceSupportBuilder.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceSupportBuilder.java new file mode 100644 index 000000000..1cd1832fd --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureSurfaceSupportBuilder.java @@ -0,0 +1,405 @@ +package art.arcane.iris.nativegen; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pools.EmptyPoolElement; +import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement; +import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement; +import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; + +final class NativeStructureSurfaceSupportBuilder { + private static final int MAX_BATCH_CELLS = 524_288; + private static final int MAX_BRIDGE_SPAN = 2; + private static final OccupancyCell BLOCKER = new OccupancyCell(null, true); + + private NativeStructureSurfaceSupportBuilder() { + } + + static Set bridgeRigidPieceSupport( + WorldGenLevel world, BoundingBox area, + List targets, + Supplier templates) { + if (targets == null || targets.isEmpty()) { + return Set.of(); + } + Map planned; + try { + BatchCellBudget budget = new BatchCellBudget(); + List requests = new ArrayList<>(); + Set seenStarts = Collections.newSetFromMap( + new IdentityHashMap<>()); + for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) { + if (target == null + || !NativeStructureSurfaceFitter.requiresSurfaceTerrain(target) + || !seenStarts.add(target.start())) { + continue; + } + collectTargetRequests( + world, area, target, templates, budget, requests); + } + planned = planWrites(world, area, requests, budget); + } catch (BatchBudgetExceeded ignored) { + return Set.of(); + } + if (planned.isEmpty()) { + return Set.of(); + } + List> ordered = new ArrayList<>(planned.entrySet()); + ordered.sort(Map.Entry.comparingByKey()); + Set written = new HashSet<>(); + for (Map.Entry entry : ordered) { + BlockPos position = BlockPos.of(entry.getKey()); + BlockState existing = world.getBlockState(position); + if (!existing.isSolid() + && !NativeStructureVegetationClearer.isTreeBlock(existing) + && existing.getFluidState().isEmpty() + && world.setBlock(position, entry.getValue(), 2)) { + written.add(entry.getKey()); + } + } + return Set.copyOf(written); + } + + private static void collectTargetRequests( + WorldGenLevel world, BoundingBox area, + NativeStructureTerrainIntegrator.TerrainTarget target, + Supplier templates, + BatchCellBudget budget, List requests) { + if (!NativeStructureSurfaceFitter.requiresSurfaceTerrain(target)) { + return; + } + List anchors = rigidAnchors(target.start()); + if (anchors.size() < 2) { + return; + } + BlockPos referencePos = referencePosition(target.start()); + for (RigidAnchor upper : anchors) { + Map lowerMeets = candidateLowerMeets( + area, upper, anchors, budget); + if (lowerMeets.isEmpty()) { + continue; + } + Map occupancy = effectiveOccupancy( + world, area, upper.piece(), lowerMeets, + referencePos, templates, budget); + Map lowest = lowestCells(occupancy); + for (Map.Entry entry : lowest.entrySet()) { + LowestCell cell = entry.getValue(); + Integer lowerMeet = lowerMeets.get(entry.getKey()); + if (lowerMeet == null || cell.occupancy().blocker() + || !isSolidBase(cell.occupancy().state()) + || cell.y() - lowerMeet < 2 + || cell.y() - lowerMeet > MAX_BRIDGE_SPAN) { + continue; + } + budget.consume(1); + requests.add(new SupportRequest( + cell.x(), cell.z(), lowerMeet, cell.y())); + } + } + } + + private static List rigidAnchors(StructureStart start) { + List anchors = new ArrayList<>(); + for (StructurePiece piece : start.getPieces()) { + if (!(piece instanceof PoolElementStructurePiece poolPiece) + || poolPiece.getElement().getProjection() + != StructureTemplatePool.Projection.RIGID) { + continue; + } + BoundingBox bounds = poolPiece.getBoundingBox(); + int meetY = bounds.minY() + poolPiece.getGroundLevelDelta() - 1; + anchors.add(new RigidAnchor(poolPiece, bounds, meetY)); + } + return List.copyOf(anchors); + } + + private static Map candidateLowerMeets( + BoundingBox area, RigidAnchor upper, List anchors, + BatchCellBudget budget) { + Map lowerMeets = new HashMap<>(); + for (RigidAnchor lower : anchors) { + budget.consume(1); + int span = upper.meetY() - lower.meetY(); + if (lower.piece() == upper.piece() || span < 1 + || span > MAX_BRIDGE_SPAN + || lower.meetY() < area.minY() || lower.meetY() > area.maxY()) { + continue; + } + int minimumX = Math.max(area.minX(), + Math.max(upper.bounds().minX(), lower.bounds().minX())); + int maximumX = Math.min(area.maxX(), + Math.min(upper.bounds().maxX(), lower.bounds().maxX())); + int minimumZ = Math.max(area.minZ(), + Math.max(upper.bounds().minZ(), lower.bounds().minZ())); + int maximumZ = Math.min(area.maxZ(), + Math.min(upper.bounds().maxZ(), lower.bounds().maxZ())); + if (minimumX > maximumX || minimumZ > maximumZ) { + continue; + } + for (int x = minimumX; x <= maximumX; x++) { + for (int z = minimumZ; z <= maximumZ; z++) { + budget.consume(1); + lowerMeets.merge(columnKey(x, z), lower.meetY(), Math::max); + } + } + } + return lowerMeets; + } + + private static Map effectiveOccupancy( + WorldGenLevel world, BoundingBox area, + PoolElementStructurePiece piece, Map lowerMeets, + BlockPos referencePos, Supplier templates, + BatchCellBudget budget) { + List leaves = new ArrayList<>(); + if (!flattenSingles(piece.getElement(), leaves)) { + return Map.of(); + } + List placements = new ArrayList<>(leaves.size()); + Map occupancy = new HashMap<>(); + for (SinglePoolElement leaf : leaves) { + StructurePlaceSettings settings = + NativeStructureReflection.resolvePlacementSettings(leaf, piece, area); + StructureTemplate template = NativeStructureReflection.resolveTemplate(leaf, templates); + List rawBlocks = + NativeStructureReflection.resolveTemplateBlocks( + template, settings, piece.getPosition()); + budget.consume(rawBlocks.size()); + placements.add(new LeafPlacement(settings, rawBlocks, template)); + for (StructureTemplate.StructureBlockInfo raw : rawBlocks) { + BlockPos position = piece.getPosition().offset( + StructureTemplate.calculateRelativePosition(settings, raw.pos())); + if (retain(position, area, lowerMeets)) { + occupancy.put(position.asLong(), BLOCKER); + } + } + } + for (LeafPlacement placement : placements) { + List processed = + NativeStructureReflection.processTemplateBlocks( + world, piece.getPosition(), referencePos, + placement.settings(), placement.rawBlocks(), + placement.template()); + budget.consume(processed.size()); + for (StructureTemplate.StructureBlockInfo block : processed) { + BlockPos position = block.pos(); + if (!retain(position, area, lowerMeets)) { + continue; + } + BlockState state = block.state() + .mirror(placement.settings().getMirror()) + .rotate(placement.settings().getRotation()); + occupancy.put(position.asLong(), new OccupancyCell(state, false)); + } + } + return occupancy; + } + + private static boolean flattenSingles( + StructurePoolElement element, List leaves) { + if (element instanceof ListPoolElement listElement) { + for (StructurePoolElement child : listElement.getElements()) { + if (!flattenSingles(child, leaves)) { + return false; + } + } + return true; + } + if (element == EmptyPoolElement.INSTANCE) { + return true; + } + if (element instanceof SinglePoolElement singleElement) { + leaves.add(singleElement); + return true; + } + return false; + } + + private static boolean retain( + BlockPos position, BoundingBox area, Map lowerMeets) { + if (!area.isInside(position)) { + return false; + } + Integer lowerMeet = lowerMeets.get(columnKey(position.getX(), position.getZ())); + return lowerMeet != null && position.getY() <= lowerMeet + MAX_BRIDGE_SPAN; + } + + private static Map lowestCells( + Map occupancy) { + Map lowest = new HashMap<>(); + for (Map.Entry entry : occupancy.entrySet()) { + BlockPos position = BlockPos.of(entry.getKey()); + long column = columnKey(position.getX(), position.getZ()); + LowestCell current = lowest.get(column); + if (current == null || position.getY() < current.y()) { + lowest.put(column, new LowestCell( + position.getX(), position.getY(), position.getZ(), + entry.getValue())); + } + } + return lowest; + } + + private static Map planWrites( + WorldGenLevel world, BoundingBox area, + List requests, BatchCellBudget budget) { + List eligible = new ArrayList<>(); + Set materialKeys = new HashSet<>(); + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + for (SupportRequest request : requests) { + BlockState lowerTerrain = world.getBlockState(position.set( + request.x(), request.lowerMeetY(), request.z())); + if (!isTerrainSupport(lowerTerrain) + || !gapIsClear(world, area, position, request, budget)) { + continue; + } + eligible.add(request); + materialKeys.add(new MaterialKey( + request.x(), request.z(), request.lowerMeetY())); + } + Map materials = new HashMap<>(); + for (MaterialKey key : materialKeys) { + materials.put(key, resolveSupportState( + world, position, key.x(), key.z(), key.lowerMeetY(), budget)); + } + Map planned = new HashMap<>(); + Set conflicts = new HashSet<>(); + eligible.sort(Comparator.comparingInt(SupportRequest::x) + .thenComparingInt(SupportRequest::z) + .thenComparingInt(SupportRequest::lowerMeetY) + .thenComparingInt(SupportRequest::baseY)); + for (SupportRequest request : eligible) { + MaterialKey materialKey = new MaterialKey( + request.x(), request.z(), request.lowerMeetY()); + BlockState material = materials.get(materialKey); + for (int y = request.lowerMeetY() + 1; y < request.baseY(); y++) { + budget.consume(1); + long positionKey = BlockPos.asLong(request.x(), y, request.z()); + if (conflicts.contains(positionKey)) { + continue; + } + BlockState existing = planned.putIfAbsent(positionKey, material); + if (existing != null && !existing.equals(material)) { + planned.remove(positionKey); + conflicts.add(positionKey); + } + } + } + return planned; + } + + private static boolean gapIsClear( + WorldGenLevel world, BoundingBox area, BlockPos.MutableBlockPos position, + SupportRequest request, BatchCellBudget budget) { + for (int y = request.lowerMeetY() + 1; y < request.baseY(); y++) { + budget.consume(1); + if (!area.isInside(position.set(request.x(), y, request.z()))) { + return false; + } + BlockState state = world.getBlockState(position); + if (state.isSolid() || NativeStructureVegetationClearer.isTreeBlock(state) + || !state.getFluidState().isEmpty()) { + return false; + } + } + return true; + } + + private static BlockState resolveSupportState( + WorldGenLevel world, BlockPos.MutableBlockPos position, + int x, int z, int lowerMeetY, BatchCellBudget budget) { + budget.consume(1); + BlockState below = world.getBlockState(position.set(x, lowerMeetY - 1, z)); + if (isTerrainSupport(below)) { + return below; + } + return world.getBlockState(position.set(x, lowerMeetY, z)); + } + + private static boolean isSolidBase(BlockState state) { + return state != null && state.isSolid() + && !state.is(Blocks.STRUCTURE_VOID) + && !state.is(Blocks.JIGSAW) + && state.getFluidState().isEmpty(); + } + + private static boolean isTerrainSupport(BlockState state) { + return state.isSolid() + && !NativeStructureVegetationClearer.isTreeBlock(state) + && state.getFluidState().isEmpty(); + } + + private static BlockPos referencePosition(StructureStart start) { + BoundingBox bounds = start.getPieces().getFirst().getBoundingBox(); + BlockPos center = bounds.getCenter(); + return new BlockPos(center.getX(), bounds.minY(), center.getZ()); + } + + private static long columnKey(int x, int z) { + return (long) x << 32 ^ z & 0xffffffffL; + } + + private record RigidAnchor( + PoolElementStructurePiece piece, BoundingBox bounds, int meetY) { + } + + private record LeafPlacement( + StructurePlaceSettings settings, + List rawBlocks, + StructureTemplate template) { + } + + private record OccupancyCell(BlockState state, boolean blocker) { + } + + private record LowestCell( + int x, int y, int z, OccupancyCell occupancy) { + } + + private record SupportRequest(int x, int z, int lowerMeetY, int baseY) { + } + + private record MaterialKey(int x, int z, int lowerMeetY) { + } + + private static final class BatchCellBudget { + private int consumed; + + private void consume(int amount) { + if (amount < 0 || consumed > MAX_BATCH_CELLS - amount) { + throw BatchBudgetExceeded.INSTANCE; + } + consumed += amount; + } + } + + private static final class BatchBudgetExceeded extends RuntimeException { + private static final BatchBudgetExceeded INSTANCE = new BatchBudgetExceeded(); + + private BatchBudgetExceeded() { + super(null, null, false, false); + } + } +} diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTerrainIntegrator.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTerrainIntegrator.java index 424713089..805b4368c 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTerrainIntegrator.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTerrainIntegrator.java @@ -114,7 +114,6 @@ public final class NativeStructureTerrainIntegrator { return; } if (mode == IrisStructureTerrainMode.VACUUM) { - carvePieceBoxes(world, area, start, terrain); return; } if (mode == IrisStructureTerrainMode.ENCASE) { @@ -267,6 +266,7 @@ public final class NativeStructureTerrainIntegrator { } IrisStructureTerrainMode mode = terrain.resolvedMode(); return mode == IrisStructureTerrainMode.ENCASE + || mode == IrisStructureTerrainMode.VACUUM || mode == IrisStructureTerrainMode.SOURCE && start.getStructure().terrainAdaptation() != TerrainAdjustment.NONE; } 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 418908063..189556619 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 @@ -360,7 +360,7 @@ final class ModdedNativeStructureStage { world, area, terrainTargets, this::resolvePaletteBlock); } catch (Throwable error) { throw NativeStructureGenerationException.failure( - "terrain carving", nativeStructureBatchContext(placementGroups), + "terrain preparation", nativeStructureBatchContext(placementGroups), chunkPos.x(), chunkPos.z(), error); } for (NativePlacementGroup group : placementGroups) { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java index 5150a8eae..089a71a40 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java @@ -63,6 +63,8 @@ public class NativeStructureFailureContractTest { String placement = source.substring(placementStart, placementEnd); assertTrue(placement.contains("\"terrain integration\"")); + assertTrue(placement.contains("\"terrain preparation\"")); + assertFalse(placement.contains("\"terrain carving\"")); assertTrue(placement.contains("prepareSurfaceStructures")); assertTrue(placement.contains("clearIntersectingVegetation")); assertTrue(placement.indexOf("clearIntersectingVegetation") diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureVacuumParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureVacuumParityTest.java new file mode 100644 index 000000000..22a4a5da5 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureVacuumParityTest.java @@ -0,0 +1,51 @@ +package art.arcane.iris.nativegen; + +import art.arcane.iris.engine.object.IrisStructureTerrain; +import art.arcane.iris.engine.object.IrisStructureTerrainMode; +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.GenerationStep; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; +import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; +import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece; +import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class NativeStructureVacuumParityTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void explicitVacuumUsesSharedThinSurfaceFittingOnModdedLoaders() { + Structure structure = new DesertPyramidStructure( + new Structure.StructureSettings( + HolderSet.empty(), Map.of(), + GenerationStep.Decoration.SURFACE_STRUCTURES, TerrainAdjustment.NONE)); + DesertPyramidPiece piece = new DesertPyramidPiece(RandomSource.create(7L), 0, 0); + StructureStart start = new StructureStart( + structure, new ChunkPos(0, 0), 0, new PiecesContainer(List.of(piece))); + NativeStructureTerrainIntegrator.TerrainTarget target = + new NativeStructureTerrainIntegrator.TerrainTarget( + "test:vacuum", start, + new IrisStructureTerrain().setMode(IrisStructureTerrainMode.VACUUM)); + + assertTrue(NativeStructureSurfaceFitter.requiresSurfaceTerrain(target)); + assertEquals(TerrainAdjustment.BEARD_THIN, + NativeStructureSurfaceFitter.effectiveSurfaceAdjustment(target)); + } +} 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 ae0b28393..22ffc198f 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 @@ -3205,7 +3205,7 @@ public final class DatapackIngestService { } int transactionCount = 0; for (Path transactionRoot : transactionRoots) { - if (isHarmlessTransactionArtifact(transactionRoot)) { + if (isHarmlessRecoveryArtifact(transactionRoot)) { continue; } transactionCount++; @@ -3214,7 +3214,7 @@ public final class DatapackIngestService { } } for (Path transactionRoot : transactionRoots) { - if (isHarmlessTransactionArtifact(transactionRoot)) { + if (isHarmlessRecoveryArtifact(transactionRoot)) { Files.deleteIfExists(transactionRoot); continue; } @@ -3242,15 +3242,25 @@ public final class DatapackIngestService { verifyDirectoryContainerIfPresent(scratchRoot.toFile(), "datapack install scratch"); List children; try (Stream paths = Files.list(scratchRoot)) { - children = paths.limit(MAX_MANAGED_PATHS + 1L).sorted().toList(); + children = paths.limit(MAX_MANAGED_PATHS + 2L).sorted().toList(); } - if (children.size() > MAX_MANAGED_PATHS) { - throw new IOException("Datapack install scratch contains too many entries"); + int managedEntries = 0; + for (Path child : children) { + if (!isHarmlessRecoveryArtifact(child)) { + managedEntries++; + } + if (managedEntries > MAX_MANAGED_PATHS) { + throw new IOException("Datapack install scratch contains too many entries"); + } } List pending = new ArrayList<>(); List backups = new ArrayList<>(); for (Path child : children) { + if (isHarmlessRecoveryArtifact(child)) { + Files.deleteIfExists(child); + continue; + } StagingScratch scratch = parseInstallScratch(scratchRoot, child); if (scratch == null) { throw new IOException("Unexpected datapack install scratch artifact " + child); @@ -3429,12 +3439,12 @@ public final class DatapackIngestService { return ownership; } - private static boolean isHarmlessTransactionArtifact(Path path) throws IOException { + private static boolean isHarmlessRecoveryArtifact(Path path) throws IOException { if (!".DS_Store".equals(path.getFileName().toString())) { return false; } if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { - throw new IOException("Suspicious datapack transaction artifact " + path); + throw new IOException("Suspicious datapack recovery artifact " + path); } return true; } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackStructurePlacementValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackStructurePlacementValidator.java index 1e3235537..4d95b5e4a 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackStructurePlacementValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackStructurePlacementValidator.java @@ -117,6 +117,7 @@ final class PackStructurePlacementValidator { registries.hooks(), blockingErrors); continue; } + validateEditableStructureTerrain(placementPath, placement, blockingErrors); Set editableStructureKeys = new HashSet<>(); for (int referenceIndex = 0; referenceIndex < references.length(); referenceIndex++) { Object rawReference = references.opt(referenceIndex); @@ -650,6 +651,20 @@ final class PackStructurePlacementValidator { } } + private static void validateEditableStructureTerrain( + String path, JSONObject placement, List blockingErrors) { + JSONObject terrain = placement.optJSONObject("terrain"); + if (terrain == null) { + return; + } + String mode = terrain.optString("mode", "SOURCE"); + if ("VACUUM".equals(mode) || "ENCASE".equals(mode)) { + blockingErrors.add(path + ".terrain.mode " + mode + + " cannot target editable Iris structures; use nativeStructures or " + + "importedStructures.adjustments for native terrain preparation."); + } + } + static void validateStructureStartPools(File structuresFolder, Set poolKeys, List blockingErrors) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecord.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecord.java index aff6e63f3..758dcf3c9 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecord.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecord.java @@ -118,6 +118,32 @@ public record NativeStructureOwnershipRecord( && chunkZ >= referenceMinChunkZ && chunkZ <= referenceMaxChunkZ; } + public NativeStructureOwnershipRecord withReferenceEnvelope( + int referenceMinChunkX, int referenceMaxChunkX, + int referenceMinChunkZ, int referenceMaxChunkZ) { + return new NativeStructureOwnershipRecord( + schema, + structureKey, + originChunkX, + originChunkZ, + placementIdentity, + baseY, + contentMinX, + contentMinY, + contentMinZ, + contentMaxX, + contentMaxY, + contentMaxZ, + locatorY, + referenceMinChunkX, + referenceMaxChunkX, + referenceMinChunkZ, + referenceMaxChunkZ, + contentFingerprint, + decision + ); + } + public OwnershipKey ownershipKey() { return new OwnershipKey(structureKey, originChunkX, originChunkZ); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java index 7f37ce474..829f25d6d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java @@ -91,7 +91,7 @@ public class IrisStructurePlacement { @Desc("If true, the structure starts at a deterministic random world Y inside [minHeight, maxHeight]. Terrain integration is then controlled independently by terrain.") private boolean underground = false; - @Desc("Terrain integration shared by editable Iris assemblies and live registered structures.") + @Desc("Terrain integration for this placement. The editable structures backend supports SOURCE, PRESERVE, BORE, and FORCE_CARVE. The nativeStructures backend supports every terrain mode.") private IrisStructureTerrain terrain = new IrisStructureTerrain(); @Desc("Optional foundation columns placed beneath the assembled structure's occupied bottom cells. Columns pass through air and fluids until they reach solid ground, up to maxDepth.") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrain.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrain.java index 9f822b3c1..5ad2fdf31 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrain.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrain.java @@ -22,12 +22,12 @@ public class IrisStructureTerrain { private static final double MAX_EROSION_FREQUENCY = 1D; private static final double MAX_LOBE_FREQUENCY = 1D; - @Desc("Terrain operation. SOURCE applies the registered native structure's authored terrain adaptation and is a no-op for editable Iris structures. PRESERVE disables terrain integration. FORCE_CARVE clears the requested envelope, while ENCASE fills it before placement so native shells are not lost to pre-carved air.") + @Desc("Terrain operation. SOURCE applies the registered native structure's authored terrain adaptation and is a no-op for editable Iris structures. PRESERVE disables terrain integration. VACUUM forces a 12-block surface bend to every rigid native piece base. BORE and FORCE_CARVE clear the requested envelope, while ENCASE fills it before placement so native shells are not lost to pre-carved air.") private IrisStructureTerrainMode mode = IrisStructureTerrainMode.SOURCE; @MinNumber(0) @MaxNumber(128) - @Desc("Horizontal clearance around the assembled pieces.") + @Desc("Horizontal clearance around the assembled pieces for BORE, FORCE_CARVE, and ENCASE. VACUUM uses its fixed 12-block terrain falloff.") private int horizontalPadding = 0; @MinNumber(0) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrainMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrainMode.java index 791346032..d4a061950 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrainMode.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructureTerrainMode.java @@ -8,6 +8,8 @@ public enum IrisStructureTerrainMode { PRESERVE, BORE, FORCE_CARVE, + + @Desc("Bends surface terrain to every rigid piece base with a 12-block falloff, even when the registered structure has no authored terrain adaptation. Terrain is raised or lowered rather than carved away.") VACUUM, @Desc("Fills the padded piece volume with solid blocks before any piece is placed so shells, walls, and floors land in solid ground instead of pre-carved air. Only air and liquid cells are filled; existing terrain and structures are never overwritten. Native pieces then carve their own interiors.") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java index 3edbbd450..65f45c6b5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java @@ -57,7 +57,7 @@ public class IrisVanillaStructureAdjustment { @Desc("Optional foundation columns placed beneath the native structure piece bases after placement.") private IrisStructureStiltSettings stilt = null; - @Desc("Optional terrain integration override. FORCE_CARVE clears every intersecting chunk before native pieces are placed, ENCASE fills it with solid blocks instead. Left unset, SOURCE replays the registered structure's authored terrain adaptation, including surface fitting, burial, and encapsulation.") + @Desc("Optional terrain integration override. VACUUM forces surface terrain to bend to every rigid native piece base even when the structure did not author terrain adaptation. BORE and FORCE_CARVE clear every intersecting chunk before native pieces are placed, while ENCASE fills it with solid blocks instead. Left unset, SOURCE replays the registered structure's authored terrain adaptation, including surface fitting, burial, and encapsulation.") private IrisStructureTerrain terrain = null; public boolean matches(String key) { 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 d72123f41..3a4859dd5 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 @@ -1714,6 +1714,41 @@ public class DatapackIngestServiceTest { assertFalse(scratch.exists()); } + @Test + public void recoveryRemovesFinderMetadataFromInstallScratch() throws Exception { + File root = temporaryFolder.newFolder("orphan-install-finder-metadata-root"); + File scratch = new File(root, ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File pending = new File(scratch, "managed-" + UUID.randomUUID()); + assertTrue(pending.mkdirs()); + Files.writeString(new File(pending, "partial.dat").toPath(), "partial", StandardCharsets.UTF_8); + File metadata = new File(scratch, ".DS_Store"); + Files.writeString(metadata.toPath(), "finder", StandardCharsets.UTF_8); + + DatapackIngestService.recoverTransactions(root, List.of()); + + assertFalse(metadata.exists()); + assertFalse(pending.exists()); + assertFalse(scratch.exists()); + } + + @Test + public void recoveryRejectsFinderMetadataDirectoryInInstallScratch() throws Exception { + File root = temporaryFolder.newFolder("orphan-install-finder-directory-root"); + File scratch = new File(root, ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File metadata = new File(scratch, ".DS_Store"); + assertTrue(metadata.mkdirs()); + + try { + DatapackIngestService.recoverTransactions(root, List.of()); + fail("Expected suspicious Finder metadata to block recovery"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("Suspicious datapack recovery artifact")); + } + assertTrue(metadata.isDirectory()); + } + @Test public void recoveryPreservesAndBlocksOnAnUnjournaledInstallBackup() throws Exception { File root = temporaryFolder.newFolder("orphan-install-backup-root"); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureTerrainBackendTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureTerrainBackendTest.java new file mode 100644 index 000000000..2f6fe8ae5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureTerrainBackendTest.java @@ -0,0 +1,111 @@ +package art.arcane.iris.core.pack; + +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class PackValidatorStructureTerrainBackendTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void rejectsNativeOnlyTerrainModesForEditablePlacementsAcrossEveryHost() throws Exception { + for (String mode : List.of("VACUUM", "ENCASE")) { + String folderName = "editable-" + mode.toLowerCase(Locale.ROOT); + File pack = temporaryFolder.newFolder(folderName); + writePlacement(pack, "dimensions/main.json", "dimension-" + folderName, + "dimension_city", false, mode); + writePlacement(pack, "regions/forest.json", "region-" + folderName, + "forest_tower", false, mode); + writePlacement(pack, "biomes/plains.json", "biome-" + folderName, + "plains_farm", false, mode); + List errors = new ArrayList<>(); + + PackStructurePlacementValidator.validateStructurePlacements( + pack, Set.of("dimension_city", "forest_tower", "plains_farm"), false, errors); + + String suffix = ".terrain.mode " + mode + " cannot target editable Iris structures; " + + "use nativeStructures or importedStructures.adjustments for native terrain preparation."; + assertEquals(List.of( + "Dimension 'main' structures[0]" + suffix, + "Region 'forest' structures[0]" + suffix, + "Biome 'plains' structures[0]" + suffix + ), errors); + } + } + + @Test + public void acceptsNativeOnlyTerrainModesForNativeStructurePlacements() throws Exception { + for (String mode : List.of("VACUUM", "ENCASE")) { + File pack = temporaryFolder.newFolder("native-" + mode.toLowerCase(Locale.ROOT)); + writePlacement(pack, "dimensions/main.json", "native-" + mode, + "minecraft:village_plains", true, mode); + List errors = new ArrayList<>(); + + PackStructurePlacementValidator.validateStructurePlacements(pack, Set.of(), false, errors); + + assertTrue(mode + ": " + errors, errors.isEmpty()); + } + } + + @Test + public void acceptsSupportedTerrainModesForEditablePlacements() throws Exception { + for (String mode : List.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE")) { + File pack = temporaryFolder.newFolder("editable-" + mode.toLowerCase(Locale.ROOT)); + writePlacement(pack, "dimensions/main.json", "editable-" + mode, + "dimension_city", false, mode); + List errors = new ArrayList<>(); + + PackStructurePlacementValidator.validateStructurePlacements( + pack, Set.of("dimension_city"), false, errors); + + assertTrue(mode + ": " + errors, errors.isEmpty()); + } + } + + @Test + public void acceptsNativeOnlyTerrainModesForImportedStructureAdjustments() { + for (String mode : List.of("VACUUM", "ENCASE")) { + JSONObject policy = new JSONObject().put("adjustments", new JSONArray().put( + new JSONObject() + .put("match", new JSONArray().put("towns_and_towers:")) + .put("terrain", new JSONObject().put("mode", mode)))); + List errors = new ArrayList<>(); + + PackDimensionValidator.validateImportedStructurePolicy( + "overworld", new JSONObject().put("importedStructures", policy), errors); + + assertTrue(mode + ": " + errors, errors.isEmpty()); + } + } + + private void writePlacement(File pack, String relativePath, String placementId, + String structureKey, boolean nativeStructure, + String terrainMode) throws Exception { + String source = nativeStructure + ? "\"nativeStructures\":[{\"structure\":\"" + structureKey + "\"}]" + : "\"structures\":[\"" + structureKey + "\"]"; + write(pack, relativePath, "{\"structures\":[{\"placementId\":\"" + placementId + + "\"," + source + ",\"terrain\":{\"mode\":\"" + terrainMode + "\"}}]}"); + } + + private void write(File root, String relativePath, String content) throws Exception { + Path path = root.toPath().resolve(relativePath); + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java index 9668fb6ad..67e4b2912 100644 --- a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java @@ -156,6 +156,7 @@ public class SchemaBuilderParityTest { JSONObject terrainDefinition = schema.getJSONObject("definitions") .getJSONObject(terrain.getString("$ref").substring("#/definitions/".length())); JSONObject terrainProperties = terrainDefinition.getJSONObject("properties"); + JSONObject terrainMode = terrainProperties.getJSONObject("mode"); JSONObject carveShape = terrainProperties.getJSONObject("shape"); JSONObject erosionStrength = terrainProperties.getJSONObject("erosionStrength"); JSONObject erosionFrequency = terrainProperties.getJSONObject("erosionFrequency"); @@ -163,10 +164,17 @@ public class SchemaBuilderParityTest { JSONObject lobeStrength = terrainProperties.getJSONObject("lobeStrength"); String carveShapeDefinition = carveShape.getString("$ref") .substring("#/definitions/".length()); + String terrainModeDefinition = terrainMode.getString("$ref") + .substring("#/definitions/".length()); assertTrue(properties.has("structures")); assertTrue(properties.has("nativeStructures")); assertTrue(properties.has("distribution")); + assertTrue(terrain.getString("description").contains( + "The editable structures backend supports SOURCE, PRESERVE, BORE, and FORCE_CARVE. " + + "The nativeStructures backend supports every terrain mode.")); + assertEquals(List.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "ENCASE"), + oneOfValues(schema.getJSONObject("definitions"), terrainModeDefinition)); assertEquals(List.of("BOX", "ROUNDED", "ERODED"), oneOfValues( schema.getJSONObject("definitions"), carveShapeDefinition)); assertEquals(0D, erosionStrength.getDouble("minimum"), 0D); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecordTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecordTest.java index 8041e2202..303432c0c 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecordTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipRecordTest.java @@ -100,6 +100,33 @@ public class NativeStructureOwnershipRecordTest { assertEquals(83L, record.placementIdentity()); } + @Test + public void referenceEnvelopeRefreshPreservesPersistedAuthority() { + NativeStructureOwnershipRecord original = record( + "nova_structures:tavern_oak", -3, 9, 83L); + + NativeStructureOwnershipRecord refreshed = original.withReferenceEnvelope( + -6, 0, 6, 12); + + assertEquals(original.schema(), refreshed.schema()); + assertEquals(original.ownershipKey(), refreshed.ownershipKey()); + assertEquals(original.placementIdentity(), refreshed.placementIdentity()); + assertEquals(original.baseY(), refreshed.baseY()); + assertEquals(original.contentMinX(), refreshed.contentMinX()); + assertEquals(original.contentMinY(), refreshed.contentMinY()); + assertEquals(original.contentMinZ(), refreshed.contentMinZ()); + assertEquals(original.contentMaxX(), refreshed.contentMaxX()); + assertEquals(original.contentMaxY(), refreshed.contentMaxY()); + assertEquals(original.contentMaxZ(), refreshed.contentMaxZ()); + assertEquals(original.locatorY(), refreshed.locatorY()); + assertEquals(original.contentFingerprint(), refreshed.contentFingerprint()); + assertEquals(original.decision(), refreshed.decision()); + assertEquals(-6, refreshed.referenceMinChunkX()); + assertEquals(0, refreshed.referenceMaxChunkX()); + assertEquals(6, refreshed.referenceMinChunkZ()); + assertEquals(12, refreshed.referenceMaxChunkZ()); + } + @Test public void ownershipRoundTripPreservesExactBoundsAndPriority() throws Exception { NativeStructureOwnershipRecord ownership = record( diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisImportedStructureControlTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisImportedStructureControlTest.java index 6ed1dd7d8..5064e5b95 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisImportedStructureControlTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisImportedStructureControlTest.java @@ -202,6 +202,35 @@ public class IrisImportedStructureControlTest { assertNull(control.resolve("minecraft:trial_chambers", true).yBand()); } + @Test + public void namespaceVacuumCanPreserveSpecificStructuresLater() { + IrisVanillaStructureAdjustment namespace = new IrisVanillaStructureAdjustment() + .setMatch(keys("towns_and_towers:")) + .setTerrain(new IrisStructureTerrain().setMode(IrisStructureTerrainMode.VACUUM)); + IrisVanillaStructureAdjustment preserveShips = new IrisVanillaStructureAdjustment() + .setMatch(keys( + "towns_and_towers:mimic_desert", + "towns_and_towers:pillager_outpost_ocean", + "towns_and_towers:village_ocean", + "towns_and_towers:wreckage_ocean")) + .setTerrain(new IrisStructureTerrain().setMode(IrisStructureTerrainMode.PRESERVE)); + KList adjustments = new KList<>(); + adjustments.add(namespace); + adjustments.add(preserveShips); + IrisImportedStructureControl control = new IrisImportedStructureControl() + .setAdjustments(adjustments); + + assertEquals(IrisStructureTerrainMode.VACUUM, + control.resolve("towns_and_towers:village_forest", false) + .terrain().resolvedMode()); + assertEquals(IrisStructureTerrainMode.PRESERVE, + control.resolve("towns_and_towers:village_ocean", false) + .terrain().resolvedMode()); + assertEquals(IrisStructureTerrainMode.PRESERVE, + control.resolve("towns_and_towers:mimic_desert", true) + .terrain().resolvedMode()); + } + @Test public void preserveSourceYMatchesTheStructureFamilyAndKeepsExplicitShifts() { IrisVanillaStructureAdjustment adjustment = new IrisVanillaStructureAdjustment()