From 967c0823721f19092d9f0a938d8ba3797d1b4d8e Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 5 Aug 2026 23:39:54 -0600 Subject: [PATCH] d --- .../core/nms/v26_2_R1/IrisChunkGenerator.java | 19 +- ...IrisChunkGeneratorFailureContractTest.java | 3 + ...ucturePostProcessorSurfaceTerrainTest.java | 491 ++++++++++++++++- ...eStructurePostProcessorVegetationTest.java | 245 +++++++-- ...risStructureLocateCommandContractTest.java | 2 +- .../NativeStructureSurfaceFitter.java | 492 +++++++++++++++++- .../NativeStructureSurfaceSupportBuilder.java | 134 +---- .../NativeStructureTemplateOccupancy.java | 147 ++++++ .../NativeStructureTerrainIntegrator.java | 1 - .../NativeStructureVegetationClearer.java | 67 ++- .../modded/ModdedNativeStructureStage.java | 19 +- .../NativeStructureFailureContractTest.java | 3 + .../IrisModdedStructureCommandTest.java | 2 +- .../NativeStructureReferenceRepairTest.java | 6 +- .../NativeStructureVacuumParityTest.java | 8 +- build.gradle | 21 +- .../arcane/iris/core/ServerConfigurator.java | 12 +- .../core/datapack/DatapackIngestService.java | 44 +- .../core/pack/PackDimensionValidator.java | 8 +- .../iris/core/pack/PackDirectoryResolver.java | 2 +- .../NativeStructureOwnershipRecord.java | 8 +- .../NativeStructurePlacementPlanner.java | 1 - .../mantle/components/CarveOrphanSweep.java | 261 ++++++++++ .../mantle/components/IrisCaveCarver3D.java | 23 +- .../components/MantleCarvingComponent.java | 17 + .../iris/engine/mode/ModeOverworld.java | 6 +- .../iris/engine/object/IrisCaveProfile.java | 10 - .../object/IrisImportedStructureControl.java | 6 +- .../object/IrisNativeStructureDecision.java | 3 +- .../engine/object/IrisStructureTerrain.java | 2 +- .../object/IrisStructureTerrainMode.java | 2 +- .../IrisVanillaStructureAdjustment.java | 7 +- .../iris/core/IrisDatapackCompilerTest.java | 18 + ...erConfiguratorDatapackFingerprintTest.java | 40 ++ .../datapack/DatapackIngestServiceTest.java | 109 +++- .../DefaultPackBootstrapProvisionerTest.java | 37 ++ .../core/pack/PackDirectoryResolverTest.java | 37 ++ ...kValidatorImportedStructurePolicyTest.java | 25 +- ...kValidatorStructureTerrainBackendTest.java | 3 +- .../NativeStructureOwnershipRecordTest.java | 23 +- .../NativeStructureOwnershipStoreTest.java | 54 +- .../NativeStructurePlacementPlannerTest.java | 1 - .../components/CarveOrphanSweepTest.java | 219 ++++++++ .../IrisCaveCarver3DNearParityTest.java | 2 - .../IrisImportedStructureControlTest.java | 10 +- .../art/arcane/iris/spi/IrisLoggingTest.java | 47 ++ .../specialhandlers/StructureHandlerTest.java | 2 +- .../java/art/arcane/iris/spi/IrisLogging.java | 1 + 48 files changed, 2351 insertions(+), 349 deletions(-) create mode 100644 adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTemplateOccupancy.java create mode 100644 core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java create mode 100644 core/src/test/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweepTest.java create mode 100644 core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java 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 3d3e4a24b..17990e7f4 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 @@ -528,7 +528,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { int steps = GenerationStep.Decoration.values().length; List placementGroups = new ArrayList<>(); List heightmapStarts = new ArrayList<>(); - List vegetationTargets = new ArrayList<>(); + List vegetationTargets = new ArrayList<>(); List terrainTargets = new ArrayList<>(); for (int step = 0; step < steps; step++) { int index = 0; @@ -559,11 +559,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { structureId, start, NativeStructureTerrainIntegrator.resolveNativeTerrain( start, decision.terrain()))); - boolean clearEntireFootprint = NativeStructureVegetationClearer - .shouldClearEntireVegetationFootprint( - structure.step(), decision.clearVegetation()); - vegetationTargets.add(new NativeStructureVegetationClearer.VegetationTarget( - start, clearEntireFootprint)); + vegetationTargets.add(start); } if (!resolvedPlacements.isEmpty()) { placementGroups.add(new NativePlacementGroup( @@ -592,8 +588,9 @@ public class IrisChunkGenerator extends CustomChunkGenerator { "vegetation cleanup", nativeStructureBatchContext(placementGroups), chunkPos.x(), chunkPos.z(), error); } + NativeStructureSurfaceFitter.VacuumFoundationPlan vacuumFoundationPlan; try { - NativeStructureSurfaceFitter.prepareSurfaceStructures( + vacuumFoundationPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures( world, area, terrainTargets, (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()); } catch (Throwable error) { @@ -621,6 +618,14 @@ public class IrisChunkGenerator extends CustomChunkGenerator { "placement", group.structureId(), chunkPos.x(), chunkPos.z(), error); } } + try { + NativeStructureSurfaceFitter.repairVacuumFoundations( + world, area, vacuumFoundationPlan); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "foundation repair", nativeStructureBatchContext(placementGroups), + chunkPos.x(), chunkPos.z(), error); + } } private static String nativeStructureBatchContext(List 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 60489ca06..265a5da93 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 @@ -45,6 +45,7 @@ public class IrisChunkGeneratorFailureContractTest { assertTrue(placement.contains("\"resolution\"")); assertTrue(placement.contains("\"terrain integration\"")); assertTrue(placement.contains("\"terrain preparation\"")); + assertTrue(placement.contains("\"foundation repair\"")); assertFalse(placement.contains("\"terrain carving\"")); assertTrue(placement.contains("\"vegetation cleanup\"")); assertTrue(placement.contains("\"placement\"")); @@ -55,6 +56,8 @@ public class IrisChunkGeneratorFailureContractTest { < placement.indexOf("prepareSurfaceStructures")); assertTrue(placement.indexOf("prepareSurfaceStructures") < placement.indexOf("for (NativePlacementGroup group")); + assertTrue(placement.indexOf("repairVacuumFoundations") + > placement.indexOf("for (NativePlacementGroup group")); assertFalse(placement.contains("IrisLogging.reportError")); } 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 605a210d3..1e6fb632f 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 @@ -40,6 +40,7 @@ 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.JigsawJunction; 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; @@ -54,6 +55,7 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProc 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 net.minecraft.world.level.levelgen.placement.PlacedFeature; import org.junit.BeforeClass; import org.junit.Test; @@ -131,7 +133,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { } @Test - public void explicitVacuumForcesThinSurfaceFittingWithoutAuthoredAdaptation() { + public void explicitVacuumUsesIndependentTerrainFittingWithoutAuthoredAdaptation() { StructureStart none = desertStart(TerrainAdjustment.NONE); StructureStart box = desertStart(TerrainAdjustment.BEARD_BOX); NativeStructureTerrainIntegrator.TerrainTarget sourceNone = @@ -150,11 +152,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { 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( + assertFalse(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir( none, vacuumNone.terrain())); } @@ -263,6 +261,476 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 11, 70, 1)); } + @Test + public void vacuumFitsOnlyProcessedSolidTemplateColumns() throws Exception { + StructureTemplate sparseTemplate = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()), + block(15, 0, 0, Blocks.STONE.defaultBlockState()))); + InlineSinglePoolElement element = new InlineSinglePoolElement( + sparseTemplate, List.of(new ReplaceBlockProcessor( + Blocks.STONE.defaultBlockState(), Blocks.AIR.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + element, new BoundingBox(0, 64, 0, 15, 70, 3), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 56, 0, 15, 72, 3); + Map blocks = flatTerrain(area, 60); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 60); + + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 0, 64, 0)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 0, 63, 0)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 15, 60, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 15, 61, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 15, 64, 0)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 15, 60, 3)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 15, 64, 3)); + } + + @Test + public void vacuumSupportsGroundDeltaZeroFoundations() throws Exception { + StructureTemplate template = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineSinglePoolElement(template), + new BoundingBox(0, 66, 0, 0, 70, 0), 0, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 54, 0, 3, 72, 3); + Map blocks = flatTerrain(area, 60); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 60); + + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 0, 65, 0)); + assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 0, 64, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 0, 66, 0)); + } + + @Test + public void vacuumFindsActualTerrainBelowNominalOpenAirSurface() throws Exception { + StructureTemplate template = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineSinglePoolElement(template), + new BoundingBox(0, 75, 0, 0, 81, 0), 2, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 54, 0, 3, 84, 3); + Map blocks = flatTerrain(area, 60); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 80); + + for (int y = 61; y <= 74; y++) { + assertFalse(state(blocks, 0, y, 0).isAir()); + } + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 0, 74, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 0, 75, 0)); + } + + @Test + public void vacuumSupportsFoundationBelowHigherNaturalSurface() throws Exception { + StructureTemplate template = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineLegacyPoolElement(template), + new BoundingBox(0, 75, 0, 0, 81, 0), 2, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 54, 0, 15, 84, 3); + Map blocks = flatTerrain(area, 60); + put(blocks, 0, 78, 0, Blocks.DIRT.defaultBlockState()); + put(blocks, 0, 79, 0, Blocks.GRASS_BLOCK.defaultBlockState()); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 79); + + for (int y = 61; y <= 74; y++) { + assertFalse(state(blocks, 0, y, 0).isAir()); + } + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 0, 75, 0)); + assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 0, 78, 0)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 0, 79, 0)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 15, 60, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 15, 61, 0)); + } + + @Test + public void vacuumRepairUsesOnlyProcessedSolidFoundationColumns() throws Exception { + StructureTemplate template = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()), + block(1, 0, 0, Blocks.STONE.defaultBlockState()), + block(2, 0, 0, Blocks.AIR.defaultBlockState()), + block(2, 2, 0, Blocks.COBBLESTONE.defaultBlockState()))); + InlineSinglePoolElement element = new InlineSinglePoolElement( + template, List.of(new ReplaceBlockProcessor( + Blocks.STONE.defaultBlockState(), Blocks.AIR.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + element, new BoundingBox(0, 65, 0, 2, 72, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 54, 0, 3, 74, 3); + Map blocks = flatTerrain(area, 60); + + NativeStructureSurfaceFitter.VacuumFoundationPlan plan = + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 60); + for (int y = 61; y <= 64; y++) { + put(blocks, 0, y, 0, Blocks.CAVE_AIR.defaultBlockState()); + } + put(blocks, 0, 65, 0, Blocks.COBBLESTONE.defaultBlockState()); + put(blocks, 1, 65, 0, Blocks.COBBLESTONE.defaultBlockState()); + put(blocks, 2, 67, 0, Blocks.COBBLESTONE.defaultBlockState()); + BlockState processorAirSupport = state(blocks, 1, 64, 0); + BlockState authoredAirSupport = state(blocks, 2, 66, 0); + + NativeStructureSurfaceFitter.repairVacuumFoundations( + world(blocks), area, plan); + + for (int y = 61; y <= 64; y++) { + assertFalse(state(blocks, 0, y, 0).isAir()); + } + assertEquals(processorAirSupport, state(blocks, 1, 64, 0)); + assertEquals(authoredAirSupport, state(blocks, 2, 66, 0)); + } + + @Test + public void vacuumRepairPreservesOverlappingAuthoredInterior() throws Exception { + PoolElementStructurePiece lower = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState()), + block(0, 1, 0, Blocks.AIR.defaultBlockState())))), + new BoundingBox(0, 61, 0, 0, 62, 0), 0, Rotation.NONE); + PoolElementStructurePiece upper = rigidTemplatePiece( + new InlineLegacyPoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 65, 0, 0, 70, 0), 0, Rotation.NONE); + StructureStart start = rigidSurfaceStart( + List.of(lower, upper), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 54, 0, 3, 72, 3); + Map blocks = flatTerrain(area, 60); + + NativeStructureSurfaceFitter.VacuumFoundationPlan plan = + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 60); + put(blocks, 0, 65, 0, Blocks.COBBLESTONE.defaultBlockState()); + + NativeStructureSurfaceFitter.repairVacuumFoundations( + world(blocks), area, plan); + + for (int y = 61; y <= 64; y++) { + assertTrue(state(blocks, 0, y, 0).isAir()); + } + } + + @Test + public void vacuumRepairIsStableAcrossSplitChunkAreas() throws Exception { + StructureTemplate template = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()), + block(1, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineLegacyPoolElement(template), + new BoundingBox(15, 65, 0, 16, 70, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + NativeStructureTerrainIntegrator.TerrainTarget target = + surfaceTarget(start, IrisStructureTerrainMode.VACUUM); + BoundingBox wideArea = new BoundingBox(0, 54, 0, 31, 72, 15); + BoundingBox westArea = new BoundingBox(0, 54, 0, 15, 72, 15); + BoundingBox eastArea = new BoundingBox(16, 54, 0, 31, 72, 15); + Map wideBlocks = flatTerrain(wideArea, 60); + Map splitBlocks = flatTerrain(wideArea, 60); + + NativeStructureSurfaceFitter.VacuumFoundationPlan widePlan = + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(wideBlocks), wideArea, List.of(target), (x, z) -> 60); + NativeStructureSurfaceFitter.VacuumFoundationPlan westPlan = + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(splitBlocks), westArea, List.of(target), (x, z) -> 60); + NativeStructureSurfaceFitter.VacuumFoundationPlan eastPlan = + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(splitBlocks), eastArea, List.of(target), (x, z) -> 60); + for (int x = 15; x <= 16; x++) { + for (int y = 61; y <= 64; y++) { + put(wideBlocks, x, y, 0, Blocks.CAVE_AIR.defaultBlockState()); + put(splitBlocks, x, y, 0, Blocks.CAVE_AIR.defaultBlockState()); + } + put(wideBlocks, x, 65, 0, Blocks.COBBLESTONE.defaultBlockState()); + put(splitBlocks, x, 65, 0, Blocks.COBBLESTONE.defaultBlockState()); + } + + NativeStructureSurfaceFitter.repairVacuumFoundations( + world(wideBlocks), wideArea, widePlan); + NativeStructureSurfaceFitter.repairVacuumFoundations( + world(splitBlocks), eastArea, eastPlan); + NativeStructureSurfaceFitter.repairVacuumFoundations( + world(splitBlocks), westArea, westPlan); + + assertEquals(wideBlocks, splitBlocks); + } + + @Test + public void vacuumNeverLowersExistingTerrainAroundSparsePieces() throws Exception { + StructureTemplate sparseTemplate = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineSinglePoolElement(sparseTemplate), + new BoundingBox(0, 56, 0, 7, 62, 7), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 48, 0, 7, 70, 7); + Map blocks = flatTerrain(area, 60); + Map originalBlocks = new HashMap<>(blocks); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 60); + + assertEquals(originalBlocks, blocks); + } + + @Test + public void vacuumDoesNotRaiseTerrainToRoofsAboveLegacyAir() throws Exception { + StructureTemplate template = template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState()), + block(0, 2, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineLegacyPoolElement(template), + new BoundingBox(0, 66, 0, 7, 72, 7), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 54, 0, 15, 74, 15); + Map blocks = flatTerrain(area, 60); + Map originalBlocks = new HashMap<>(blocks); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 60); + + assertEquals(originalBlocks, blocks); + } + + @Test + public void vacuumCapsNeighborTaperAtAuthoredAirGroundPlanes() throws Exception { + PoolElementStructurePiece roof = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.AIR.defaultBlockState()), + block(0, 2, 0, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(1, 66, 0, 1, 72, 0), 1, Rotation.NONE); + PoolElementStructurePiece highChild = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 90, 0, 0, 94, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart( + List.of(roof, highChild), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 54, 0, 15, 96, 15); + Map blocks = flatTerrain(area, 60); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 60); + + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 1, 66, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 1, 67, 0)); + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 0, 89, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 0, 90, 0)); + } + + @Test + public void vacuumDoesNotAnchorRoofsAboveProcessorCreatedAir() throws Exception { + StructureTemplate template = template(List.of( + block(0, 0, 0, Blocks.STONE.defaultBlockState()), + block(0, 2, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineSinglePoolElement(template, List.of(new ReplaceBlockProcessor( + Blocks.STONE.defaultBlockState(), Blocks.AIR.defaultBlockState()))), + new BoundingBox(0, 66, 0, 7, 72, 7), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 54, 0, 15, 74, 15); + Map blocks = flatTerrain(area, 60); + Map originalBlocks = new HashMap<>(blocks); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 60); + + assertEquals(originalBlocks, blocks); + } + + @Test + public void vacuumDoesNotTreatSparseRoofCellsAsFoundations() throws Exception { + StructureTemplate template = template(List.of( + block(0, 0, 0, Blocks.SANDSTONE.defaultBlockState()), + block(1, 20, 0, Blocks.SANDSTONE.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineSinglePoolElement(template), + new BoundingBox(0, 66, 0, 20, 100, 20), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 54, 0, 20, 102, 20); + Map blocks = flatTerrain(area, 60); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 60); + + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 0, 65, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 0, 66, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 1, 67, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 1, 86, 0)); + } + + @Test + public void vacuumDoesNotFlattenTerrainMatchingPoolPieces() throws Exception { + StructureTemplate template = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineSinglePoolElement( + template, List.of(), + StructureTemplatePool.Projection.TERRAIN_MATCHING), + new BoundingBox(0, 68, 0, 15, 72, 15), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 54, 0, 15, 74, 15); + Map blocks = flatTerrain(area, 60); + Map originalBlocks = new HashMap<>(blocks); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 60); + + assertEquals(originalBlocks, blocks); + } + + @Test + public void vacuumRetainsJunctionSmoothingForRigidFeaturePieces() { + StructurePoolElement feature = StructurePoolElement.feature( + Holder.direct(null)) + .apply(StructureTemplatePool.Projection.RIGID); + PoolElementStructurePiece piece = rigidTemplatePiece( + feature, new BoundingBox(4, 66, 4, 4, 70, 4), 1, Rotation.NONE); + piece.addJunction(new JigsawJunction( + 4, 66, 4, 0, StructureTemplatePool.Projection.RIGID)); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 54, 0, 15, 72, 15); + Map blocks = flatTerrain(area, 60); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 60); + + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 4, 61, 4)); + } + + @Test + public void vacuumDoesNotInventNonPoolBoundingBoxFootprints() { + StructureStart start = desertStart(TerrainAdjustment.NONE); + BoundingBox bounds = start.getBoundingBox(); + BoundingBox area = new BoundingBox( + bounds.minX() - 12, 40, bounds.minZ() - 12, + bounds.maxX() + 12, bounds.maxY() + 12, bounds.maxZ() + 12); + Map blocks = flatTerrain(area, 50); + Map originalBlocks = new HashMap<>(blocks); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 50); + + assertEquals(originalBlocks, blocks); + } + + @Test + public void vacuumBudgetExhaustionSkipsFittingWithoutFailing() throws Exception { + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 66, 0, 0, 70, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 54, 0, 15, 72, 15); + Map blocks = flatTerrain(area, 60); + Map originalBlocks = new HashMap<>(blocks); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 60, 1); + + assertEquals(originalBlocks, blocks); + } + + @Test + public void vacuumContinuouslySupportsProcessedChildBases() throws Exception { + PoolElementStructurePiece center = rigidTemplatePiece( + new InlineSinglePoolElement(template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))), + new BoundingBox(0, 60, 0, 0, 64, 0), 1, Rotation.NONE); + StructureTemplate childTemplate = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece child = rigidTemplatePiece( + new InlineSinglePoolElement(childTemplate), + new BoundingBox(10, 66, 0, 10, 72, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart( + List.of(center, child), TerrainAdjustment.NONE); + BoundingBox area = new BoundingBox(0, 54, 0, 15, 74, 3); + Map blocks = flatTerrain(area, 60); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(blocks), area, + List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)), + (x, z) -> 60); + + for (int y = 61; y <= 65; y++) { + assertFalse(state(blocks, 10, y, 0).isAir()); + } + assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 10, 65, 0)); + assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 10, 66, 0)); + } + + @Test + public void vacuumSurfaceFittingIsStableAcrossSplitChunkAreas() throws Exception { + StructureTemplate template = template(List.of( + block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState()), + block(1, 0, 0, Blocks.COBBLESTONE.defaultBlockState()))); + PoolElementStructurePiece piece = rigidTemplatePiece( + new InlineSinglePoolElement(template), + new BoundingBox(15, 66, 0, 16, 72, 0), 1, Rotation.NONE); + StructureStart start = rigidSurfaceStart(List.of(piece), TerrainAdjustment.NONE); + NativeStructureTerrainIntegrator.TerrainTarget target = + surfaceTarget(start, IrisStructureTerrainMode.VACUUM); + BoundingBox wideArea = new BoundingBox(0, 54, 0, 31, 74, 15); + BoundingBox westArea = new BoundingBox(0, 54, 0, 15, 74, 15); + BoundingBox eastArea = new BoundingBox(16, 54, 0, 31, 74, 15); + Map wideBlocks = flatTerrain(wideArea, 60); + Map splitBlocks = flatTerrain(wideArea, 60); + Map reverseSplitBlocks = flatTerrain(wideArea, 60); + + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(wideBlocks), wideArea, List.of(target), (x, z) -> 60); + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(splitBlocks), westArea, List.of(target), (x, z) -> 60); + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(splitBlocks), eastArea, List.of(target), (x, z) -> 60); + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(reverseSplitBlocks), eastArea, List.of(target), (x, z) -> 60); + NativeStructureSurfaceFitter.prepareSurfaceStructures( + world(reverseSplitBlocks), westArea, List.of(target), (x, z) -> 60); + + assertEquals(wideBlocks, splitBlocks); + assertEquals(wideBlocks, reverseSplitBlocks); + } + @Test public void surfaceAnchorIsFlushInsideAndUnchangedAtRadius() { NativeStructureSurfaceFitter.SurfaceAnchor anchor = anchor(72, 2); @@ -863,8 +1331,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { BoundingBox area = new BoundingBox(0, 63, 0, 0, 68, 0); NativeStructureVegetationClearer.clearIntersectingVegetation( - world, chunk, area, List.of( - new NativeStructureVegetationClearer.VegetationTarget(desertStart(), false))); + world, chunk, area, List.of(desertStart())); NativeStructureSurfaceFitter.applySurfaceColumn( world, new BlockPos.MutableBlockPos(), 0, 0, 64, 68, -64, 319); @@ -1642,9 +2109,15 @@ public class NativeStructurePostProcessorSurfaceTerrainTest { private InlineSinglePoolElement( StructureTemplate template, List processors) { + this(template, processors, StructureTemplatePool.Projection.RIGID); + } + + private InlineSinglePoolElement( + StructureTemplate template, List processors, + StructureTemplatePool.Projection projection) { super(Either.right(template), Holder.direct(new StructureProcessorList(processors)), - StructureTemplatePool.Projection.RIGID, + projection, Optional.empty()); } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java index af7bb22eb..0c464fc4d 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java @@ -1,27 +1,55 @@ package art.arcane.iris.nativegen; +import com.mojang.serialization.Codec; 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.server.Bootstrap; import net.minecraft.util.RandomSource; import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.PalettedContainer; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.Strategy; +import net.minecraft.world.level.chunk.UpgradeData; import net.minecraft.world.level.levelgen.GenerationStep; import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.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.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; import java.util.List; +import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class NativeStructurePostProcessorVegetationTest { + private static final long TEST_SEED = 1234L; + private static final int MIN_Y = -64; + private static final int MAX_Y = 319; + private static final int GROUND_Y = 64; + private static final BoundingBox AREA = new BoundingBox(0, MIN_Y, 0, 15, MAX_Y, 15); + @BeforeClass public static void bootstrapMinecraftRegistries() { SharedConstants.tryDetectVersion(); @@ -29,52 +57,108 @@ public class NativeStructurePostProcessorVegetationTest { } @Test - public void surfaceStructuresClearTreeColumnsAutomatically() { - assertTrue(NativeStructureVegetationClearer.shouldClearVegetationColumn(100, 100, false)); - assertTrue(NativeStructureVegetationClearer.shouldClearVegetationColumn(116, 100, false)); + public void canopyAboveTheStructureRoofSurvivesVegetationClearing() { + ProtoChunk chunk = chunk(); + StructureStart start = desertStart(0, 0, GROUND_Y + 15); + BoundingBox bounds = start.getBoundingBox(); + for (int y = bounds.maxY() - 1; y <= GROUND_Y + 40; y++) { + fillLayer(chunk, y, leaves()); + } + write(chunk, 4, bounds.minY() - 1, 4, log()); + + clearVegetation(chunk, start); + + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + assertEquals(leaves(), chunk.getBlockState(new BlockPos(x, GROUND_Y + 40, z))); + assertEquals(leaves(), chunk.getBlockState(new BlockPos(x, bounds.maxY() + 1, z))); + assertEquals(air(), chunk.getBlockState(new BlockPos(x, bounds.maxY(), z))); + assertEquals(air(), chunk.getBlockState(new BlockPos(x, bounds.maxY() - 1, z))); + } + } + assertEquals(air(), chunk.getBlockState(new BlockPos(4, bounds.minY() - 1, 4))); } @Test - public void buriedStructuresPreserveUnrelatedSurfaceForest() { - assertFalse(NativeStructureVegetationClearer.shouldClearVegetationColumn(99, 100, false)); - assertFalse(NativeStructureVegetationClearer.shouldClearVegetationColumn(20, 100, false)); + public void buriedStructuresPreserveTheForestAboveThem() { + ProtoChunk chunk = chunk(); + StructureStart start = desertStart(0, 0, -20); + write(chunk, 8, GROUND_Y + 1, 8, log()); + write(chunk, 8, GROUND_Y + 2, 8, log()); + write(chunk, 8, GROUND_Y + 3, 8, log()); + write(chunk, 8, GROUND_Y + 4, 8, leaves()); + + clearVegetation(chunk, start); + + assertEquals(log(), chunk.getBlockState(new BlockPos(8, GROUND_Y + 1, 8))); + assertEquals(log(), chunk.getBlockState(new BlockPos(8, GROUND_Y + 2, 8))); + assertEquals(log(), chunk.getBlockState(new BlockPos(8, GROUND_Y + 3, 8))); + assertEquals(leaves(), chunk.getBlockState(new BlockPos(8, GROUND_Y + 4, 8))); } @Test - public void explicitVegetationOptionForcesUnusualPlacementCleanup() { - assertTrue(NativeStructureVegetationClearer.shouldClearVegetationColumn(20, 100, true)); + public void columnsOutsideThePieceFootprintKeepEveryTreeBlock() { + ProtoChunk chunk = chunk(); + StructureStart start = desertStart(8, 8, GROUND_Y + 15); + BoundingBox bounds = start.getBoundingBox(); + int inside = bounds.minY() + 4; + write(chunk, 0, inside, 0, log()); + write(chunk, 0, GROUND_Y + 40, 0, leaves()); + write(chunk, 12, inside, 12, log()); + write(chunk, 12, GROUND_Y + 40, 12, leaves()); + + clearVegetation(chunk, start); + + assertEquals(log(), chunk.getBlockState(new BlockPos(0, inside, 0))); + assertEquals(leaves(), chunk.getBlockState(new BlockPos(0, GROUND_Y + 40, 0))); + assertEquals(air(), chunk.getBlockState(new BlockPos(12, inside, 12))); + assertEquals(leaves(), chunk.getBlockState(new BlockPos(12, GROUND_Y + 40, 12))); } @Test - public void nonForcedValidTargetsReachIntersectionProcessing() { - Structure structure = new DesertPyramidStructure( - new Structure.StructureSettings(HolderSet.empty())); - StructureStart start = new StructureStart( - structure, new ChunkPos(0, 0), 0, - new PiecesContainer(List.of( - new DesertPyramidPiece(RandomSource.create(1L), 0, 0)))); + public void clearingStopsAtThePieceEnvelopeBoundaries() { + ProtoChunk chunk = chunk(); + StructureStart start = desertStart(0, 0, GROUND_Y + 15); + BoundingBox bounds = start.getBoundingBox(); + write(chunk, 4, bounds.maxY() + 1, 4, log()); + write(chunk, 4, bounds.maxY(), 4, log()); + write(chunk, 4, bounds.minY() - 1, 4, log()); + write(chunk, 4, bounds.minY() - 2, 4, log()); - assertTrue(NativeStructureVegetationClearer.shouldProcessTarget( - new NativeStructureVegetationClearer.VegetationTarget(start, false))); - assertFalse(NativeStructureVegetationClearer.shouldProcessTarget( - new NativeStructureVegetationClearer.VegetationTarget( - StructureStart.INVALID_START, true))); + clearVegetation(chunk, start); + + assertEquals(log(), chunk.getBlockState(new BlockPos(4, bounds.maxY() + 1, 4))); + assertEquals(air(), chunk.getBlockState(new BlockPos(4, bounds.maxY(), 4))); + assertEquals(air(), chunk.getBlockState(new BlockPos(4, bounds.minY() - 1, 4))); + assertEquals(log(), chunk.getBlockState(new BlockPos(4, bounds.minY() - 2, 4))); } @Test - public void surfaceStructuresPreserveVegetationUnlessConfigured() { - assertFalse(NativeStructureVegetationClearer.shouldClearEntireVegetationFootprint( - GenerationStep.Decoration.SURFACE_STRUCTURES, false)); - assertTrue(NativeStructureVegetationClearer.shouldClearEntireVegetationFootprint( - GenerationStep.Decoration.SURFACE_STRUCTURES, true)); + public void overlappingPieceEnvelopesMergeIntoOneClearedRange() { + ProtoChunk chunk = chunk(); + StructureStart low = desertStart(0, 0, GROUND_Y); + StructureStart high = desertStart(0, 0, GROUND_Y + 40); + int between = GROUND_Y + 20; + write(chunk, 4, between, 4, log()); + write(chunk, 4, high.getBoundingBox().maxY() + 1, 4, log()); + write(chunk, 4, low.getBoundingBox().minY() - 2, 4, log()); + + NativeStructureVegetationClearer.clearIntersectingVegetation( + world(chunk), chunk, AREA, List.of(low, high)); + + assertEquals(air(), chunk.getBlockState(new BlockPos(4, between, 4))); + assertEquals(log(), chunk.getBlockState( + new BlockPos(4, high.getBoundingBox().maxY() + 1, 4))); + assertEquals(log(), chunk.getBlockState( + new BlockPos(4, low.getBoundingBox().minY() - 2, 4))); } @Test - public void undergroundStructuresPreserveSurfaceVegetationUnlessConfigured() { - assertFalse(NativeStructureVegetationClearer.shouldClearEntireVegetationFootprint( - GenerationStep.Decoration.UNDERGROUND_STRUCTURES, false)); - assertTrue(NativeStructureVegetationClearer.shouldClearEntireVegetationFootprint( - GenerationStep.Decoration.UNDERGROUND_STRUCTURES, true)); + public void validStartsReachIntersectionProcessing() { + assertTrue(NativeStructureVegetationClearer.shouldProcessStart( + desertStart(0, 0, GROUND_Y + 15))); + assertFalse(NativeStructureVegetationClearer.shouldProcessStart( + StructureStart.INVALID_START)); } @Test @@ -103,4 +187,105 @@ public class NativeStructurePostProcessorVegetationTest { assertEquals(-2, NativeStructureVerticalPlacer.resolveBuriedOffset( bounds, 0, 58, 320, (x, z) -> x == 1 ? 76 : 100)); } + + private static void clearVegetation(ProtoChunk chunk, StructureStart start) { + NativeStructureVegetationClearer.clearIntersectingVegetation( + world(chunk), chunk, AREA, List.of(start)); + } + + private static StructureStart desertStart(int originX, int originZ, int topY) { + Structure structure = new DesertPyramidStructure( + new Structure.StructureSettings( + HolderSet.empty(), Map.of(), + GenerationStep.Decoration.SURFACE_STRUCTURES, TerrainAdjustment.NONE)); + DesertPyramidPiece piece = new DesertPyramidPiece(RandomSource.create(7L), originX, originZ); + piece.move(0, topY - piece.getBoundingBox().maxY(), 0); + return new StructureStart( + structure, new ChunkPos(0, 0), 0, new PiecesContainer(List.of(piece))); + } + + private static ProtoChunk chunk() { + return new ProtoChunk( + new ChunkPos(0, 0), UpgradeData.EMPTY, + LevelHeightAccessor.create(MIN_Y, MAX_Y - MIN_Y + 1), containerFactory(), null); + } + + private static void fillLayer(ProtoChunk chunk, int y, BlockState state) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + write(chunk, x, y, z, state); + } + } + } + + private static void write(ProtoChunk chunk, int x, int y, int z, BlockState state) { + LevelChunkSection section = chunk.getSection(chunk.getSectionIndex(y)); + section.setBlockState(x, y & 15, z, state, false); + } + + private static BlockState log() { + return Blocks.OAK_LOG.defaultBlockState(); + } + + private static BlockState leaves() { + return Blocks.OAK_LEAVES.defaultBlockState(); + } + + private static BlockState air() { + return Blocks.AIR.defaultBlockState(); + } + + private static WorldGenLevel world(ChunkAccess chunk) { + InvocationHandler handler = (proxy, method, arguments) -> { + String methodName = method.getName(); + if (methodName.equals("getBlockState")) { + return chunk.getBlockState((BlockPos) arguments[0]); + } + if (methodName.equals("setBlock")) { + return chunk.setBlockState( + ((BlockPos) arguments[0]).immutable(), + (BlockState) arguments[1], (int) arguments[2]) != null; + } + if (methodName.equals("getSeed")) { + return TEST_SEED; + } + if (methodName.equals("getLevel")) { + return null; + } + if (methodName.equals("holderLookup")) { + return BuiltInRegistries.BLOCK; + } + if (methodName.equals("hashCode")) { + return System.identityHashCode(proxy); + } + if (methodName.equals("equals")) { + return proxy == arguments[0]; + } + if (methodName.equals("toString")) { + return "vegetation-test-world"; + } + throw new UnsupportedOperationException(method.toString()); + }; + return (WorldGenLevel) Proxy.newProxyInstance( + WorldGenLevel.class.getClassLoader(), new Class[]{WorldGenLevel.class}, handler); + } + + private static PalettedContainerFactory containerFactory() { + Strategy blockStates = Strategy.createForBlockStates(Block.BLOCK_STATE_REGISTRY); + BlockState air = Blocks.AIR.defaultBlockState(); + IdMapper> biomeIds = new IdMapper<>(); + Holder defaultBiome = Holder.direct((Biome) null); + biomeIds.add(defaultBiome); + Strategy> biomes = Strategy.createForBiomes(biomeIds); + Codec> biomeCodec = Codec.STRING.xmap( + name -> defaultBiome, holder -> "iris-test-biome"); + return new PalettedContainerFactory( + blockStates, + air, + PalettedContainer.codecRW(BlockState.CODEC, blockStates, air), + biomes, + defaultBiome, + PalettedContainer.codecRO(biomeCodec, biomes, defaultBiome), + PalettedContainer.codecRW(biomeCodec, biomes, defaultBiome)); + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java index 56a3b6db9..2b8654c0d 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java @@ -258,6 +258,6 @@ public class IrisStructureLocateCommandContractTest { private static IrisNativeStructureDecision decision(NativeStructureGenerationStatus status) { return new IrisNativeStructureDecision( - status, 0, null, false, false, null, null); + status, 0, null, false, null, null); } } 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 1fe35ab30..57db72007 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 @@ -3,6 +3,7 @@ package art.arcane.iris.nativegen; import art.arcane.iris.engine.object.IrisObjectVacuum; import art.arcane.iris.engine.object.IrisStructureTerrain; import art.arcane.iris.engine.object.IrisStructureTerrainMode; +import art.arcane.iris.spi.IrisLogging; import net.minecraft.core.BlockPos; import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.block.Blocks; @@ -17,29 +18,94 @@ import net.minecraft.world.level.levelgen.structure.pools.JigsawJunction; import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.IntBinaryOperator; public final class NativeStructureSurfaceFitter { private static final double SURFACE_TERRAIN_FALLOFF = 2.0; private static final long SURFACE_TERRAIN_INFLUENCE_SCALE = 1_000_000L; private static final int SURFACE_TERRAIN_RADIUS = 12; + private static final int MAX_VACUUM_TEMPLATE_CELLS = 4_194_304; + private static final Set WARNED_VACUUM_BUDGET = + ConcurrentHashMap.newKeySet(); private NativeStructureSurfaceFitter() { } - public static void prepareSurfaceStructures(WorldGenLevel world, BoundingBox area, - List targets, - IntBinaryOperator surfaceHeight) { + public static VacuumFoundationPlan prepareSurfaceStructures( + WorldGenLevel world, BoundingBox area, + List targets, + IntBinaryOperator surfaceHeight) { + return prepareSurfaceStructures( + world, area, targets, surfaceHeight, MAX_VACUUM_TEMPLATE_CELLS); + } + + static VacuumFoundationPlan prepareSurfaceStructures( + WorldGenLevel world, BoundingBox area, + List targets, + IntBinaryOperator surfaceHeight, int maximumVacuumTemplateCells) { if (targets == null || targets.isEmpty()) { - return; + return VacuumFoundationPlan.empty(); } Objects.requireNonNull(surfaceHeight, "Surface structure terrain fitting requires an Iris height resolver"); - List anchors = collectSurfaceAnchors(targets); + List anchors = collectSourceSurfaceAnchors(targets); if (!anchors.isEmpty()) { fitSurfaceTerrain(world, area, anchors, surfaceHeight); } + VacuumFootprint vacuum = collectVacuumFootprint( + world, area, targets, maximumVacuumTemplateCells); + if (!vacuum.anchors().isEmpty()) { + fitVacuumTerrain(world, area, vacuum, surfaceHeight); + } + return VacuumFoundationPlan.create( + vacuum.foundationBases(), vacuum.occupiedCells(), area); + } + + public static void repairVacuumFoundations( + WorldGenLevel world, BoundingBox area, + VacuumFoundationPlan plan) { + if (plan == null || plan.foundationBases.isEmpty()) { + return; + } + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + for (long packedBase : plan.foundationBases) { + BlockPos base = BlockPos.of(packedBase); + if (!area.isInside(base)) { + continue; + } + BlockState baseState = world.getBlockState(position.set(base)); + if (!NativeStructureTemplateOccupancy.isSolidBase(baseState)) { + continue; + } + int supportY = findVacuumFoundationSupport( + world, area, position, base.getX(), base.getY() - 1, base.getZ()); + if (supportY == Integer.MIN_VALUE || supportY == base.getY() - 1) { + continue; + } + if (intersectsVacuumOccupancy( + plan.occupiedCells, base.getX(), base.getZ(), + supportY + 1, base.getY() - 1)) { + continue; + } + BlockState fill = resolveSurfaceMaterials( + world, position, base.getX(), base.getZ(), supportY, + area.minY()).subsurface(); + for (int y = supportY + 1; y < base.getY(); y++) { + BlockState existing = world.getBlockState( + position.set(base.getX(), y, base.getZ())); + if (existing.isAir() || !existing.getFluidState().isEmpty()) { + world.setBlock(position, fill, 2); + } + } + } } static boolean shouldPrepareSurfaceTerrain(TerrainAdjustment adjustment, @@ -156,15 +222,15 @@ public final class NativeStructureSurfaceFitter { return false; } - private static List collectSurfaceAnchors( + private static List collectSourceSurfaceAnchors( List targets) { List anchors = new ArrayList<>(); for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) { - if (!requiresSurfaceTerrain(target)) { + if (!requiresSourceSurfaceTerrain(target)) { continue; } StructureStart start = target.start(); - TerrainAdjustment adjustment = effectiveSurfaceAdjustment(target); + TerrainAdjustment adjustment = start.getStructure().terrainAdaptation(); for (StructurePiece piece : start.getPieces()) { if (piece instanceof PoolElementStructurePiece poolPiece) { if (poolPiece.getElement().getProjection() == StructureTemplatePool.Projection.RIGID) { @@ -189,6 +255,146 @@ public final class NativeStructureSurfaceFitter { return List.copyOf(anchors); } + private static VacuumFootprint collectVacuumFootprint( + WorldGenLevel world, BoundingBox area, + List targets, + int maximumTemplateCells) { + BoundingBox influenceArea = new BoundingBox( + area.minX() - SURFACE_TERRAIN_RADIUS, area.minY(), + area.minZ() - SURFACE_TERRAIN_RADIUS, + area.maxX() + SURFACE_TERRAIN_RADIUS, area.maxY(), + area.maxZ() + SURFACE_TERRAIN_RADIUS); + Map anchors = new HashMap<>(); + Map columnCaps = new HashMap<>(); + Set foundationBases = new HashSet<>(); + Set occupiedCells = new HashSet<>(); + Set seenStarts = Collections.newSetFromMap( + new IdentityHashMap<>()); + for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) { + if (target == null || target.terrain() == null + || target.terrain().resolvedMode() != IrisStructureTerrainMode.VACUUM + || target.start() == null || !target.start().isValid() + || !seenStarts.add(target.start())) { + continue; + } + StructureStart start = target.start(); + BoundingBox firstBounds = start.getPieces().getFirst().getBoundingBox(); + BlockPos firstCenter = firstBounds.getCenter(); + BlockPos referencePosition = new BlockPos( + firstCenter.getX(), firstBounds.minY(), firstCenter.getZ()); + Map targetAnchors = new HashMap<>(); + Map targetCaps = new HashMap<>(); + Set targetFoundationBases = new HashSet<>(); + Set targetOccupiedCells = new HashSet<>(); + VacuumCellBudget budget = new VacuumCellBudget(maximumTemplateCells); + try { + for (StructurePiece piece : start.getPieces()) { + BoundingBox bounds = piece.getBoundingBox(); + if (!intersectsHorizontally(bounds, influenceArea) + || !(piece instanceof PoolElementStructurePiece poolPiece) + || poolPiece.getElement().getProjection() + != StructureTemplatePool.Projection.RIGID) { + continue; + } + addVacuumJunctionAnchors( + anchors, influenceArea, poolPiece); + NativeStructureTemplateOccupancy.OccupancyResult occupancy = + NativeStructureTemplateOccupancy.resolve( + world, poolPiece, referencePosition, influenceArea, + () -> world.getLevel().getStructureManager(), + influenceArea::isInside, budget::consume); + if (!occupancy.resolved()) { + continue; + } + targetOccupiedCells.addAll(occupancy.cells().keySet()); + Map lowest = + NativeStructureTemplateOccupancy.lowestCells( + occupancy.cells()); + int groundY = bounds.minY() + + poolPiece.getGroundLevelDelta() - 1; + for (NativeStructureTemplateOccupancy.LowestCell cell : lowest.values()) { + budget.consume(1); + long column = NativeStructureTemplateOccupancy.columnKey( + cell.x(), cell.z()); + targetCaps.merge( + column, Math.min(cell.y(), groundY), Math::min); + if (cell.occupancy().blocker() + || cell.y() > groundY + 1 + || !NativeStructureTemplateOccupancy.isSolidBase( + cell.occupancy().state())) { + continue; + } + addVacuumAnchor(targetAnchors, cell.x(), cell.z(), + new VacuumAnchor(cell.y() - 1, 2)); + targetFoundationBases.add(BlockPos.asLong( + cell.x(), cell.y(), cell.z())); + } + } + } catch (VacuumBudgetExceeded ignored) { + warnVacuumBudget(target); + continue; + } + targetAnchors.forEach((column, anchor) -> anchors.merge( + column, anchor, NativeStructureSurfaceFitter::preferredVacuumAnchor)); + targetCaps.forEach((column, cap) -> columnCaps.merge( + column, cap, Math::min)); + foundationBases.addAll(targetFoundationBases); + occupiedCells.addAll(targetOccupiedCells); + } + return new VacuumFootprint( + Map.copyOf(anchors), Map.copyOf(columnCaps), + Set.copyOf(foundationBases), Set.copyOf(occupiedCells)); + } + + private static void addVacuumJunctionAnchors( + Map anchors, BoundingBox influenceArea, + PoolElementStructurePiece poolPiece) { + for (JigsawJunction junction : poolPiece.getJunctions()) { + if (junction.getSourceX() < influenceArea.minX() + || junction.getSourceX() > influenceArea.maxX() + || junction.getSourceZ() < influenceArea.minZ() + || junction.getSourceZ() > influenceArea.maxZ()) { + continue; + } + addVacuumAnchor(anchors, + junction.getSourceX(), junction.getSourceZ(), + new VacuumAnchor(junction.getSourceGroundY() - 1, 1)); + } + } + + private static void warnVacuumBudget( + NativeStructureTerrainIntegrator.TerrainTarget target) { + String structureId = target.structureId() == null + ? target.start().getStructure().getClass().getName() + : target.structureId(); + if (WARNED_VACUUM_BUDGET.add(structureId)) { + IrisLogging.warn("Native structure VACUUM fitting for '" + + structureId + "' exceeded its bounded template budget; " + + "using jigsaw junction smoothing for oversized starts"); + } + } + + private static boolean intersectsHorizontally( + BoundingBox first, BoundingBox second) { + return first.maxX() >= second.minX() && first.minX() <= second.maxX() + && first.maxZ() >= second.minZ() && first.minZ() <= second.maxZ(); + } + + private static void addVacuumAnchor( + Map anchors, int x, int z, + VacuumAnchor candidate) { + anchors.merge(NativeStructureTemplateOccupancy.columnKey(x, z), candidate, + NativeStructureSurfaceFitter::preferredVacuumAnchor); + } + + private static VacuumAnchor preferredVacuumAnchor( + VacuumAnchor first, VacuumAnchor second) { + if (first.strength() != second.strength()) { + return first.strength() > second.strength() ? first : second; + } + return first.surfaceY() <= second.surfaceY() ? first : second; + } + static SurfaceAnchor surfaceAnchor(BoundingBox bounds, int groundY, int strength, TerrainAdjustment adjustment) { int meetY = groundY - 1; @@ -216,17 +422,17 @@ public final class NativeStructureSurfaceFitter { } IrisStructureTerrainMode mode = target.terrain().resolvedMode(); return mode == IrisStructureTerrainMode.VACUUM - || mode == IrisStructureTerrainMode.SOURCE - && shouldPrepareSurfaceTerrain( - start.getStructure().terrainAdaptation(), start.getStructure().step()); + || requiresSourceSurfaceTerrain(target); } - static TerrainAdjustment effectiveSurfaceAdjustment( + private static boolean requiresSourceSurfaceTerrain( NativeStructureTerrainIntegrator.TerrainTarget target) { - if (target.terrain().resolvedMode() == IrisStructureTerrainMode.VACUUM) { - return TerrainAdjustment.BEARD_THIN; - } - return target.start().getStructure().terrainAdaptation(); + return target != null && target.terrain() != null + && target.start() != null && target.start().isValid() + && target.terrain().resolvedMode() == IrisStructureTerrainMode.SOURCE + && shouldPrepareSurfaceTerrain( + target.start().getStructure().terrainAdaptation(), + target.start().getStructure().step()); } private static void fitSurfaceTerrain(WorldGenLevel world, BoundingBox area, @@ -260,6 +466,168 @@ public final class NativeStructureSurfaceFitter { } } + private static void fitVacuumTerrain( + WorldGenLevel world, BoundingBox area, VacuumFootprint footprint, + IntBinaryOperator surfaceHeight) { + int width = area.getXSpan(); + int depth = area.getZSpan(); + int[] originalHeights = new int[width * depth]; + int[] targetHeights = new int[width * depth]; + boolean[] rigidBaseSupport = new boolean[width * depth]; + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + for (int z = area.minZ(); z <= area.maxZ(); z++) { + for (int x = area.minX(); x <= area.maxX(); x++) { + int column = (z - area.minZ()) * width + x - area.minX(); + int nominalY = Math.max(area.minY(), Math.min( + area.maxY(), surfaceHeight.applyAsInt(x, z))); + long columnKey = NativeStructureTemplateOccupancy.columnKey(x, z); + VacuumAnchor local = footprint.anchors().get(columnKey); + Integer cap = footprint.columnCaps().get(columnKey); + boolean suppressed = local != null && local.strength() > 1 + && cap != null && cap < local.surfaceY(); + int scanStartY = local != null && local.strength() > 1 && !suppressed + ? Math.max(area.minY(), Math.min(area.maxY(), local.surfaceY())) + : nominalY; + int originalY = resolveVacuumSurfaceHeight( + world, position, x, z, scanStartY, area.minY()); + SurfaceResolution resolution = resolveVacuumSurface( + footprint, x, z, originalY); + originalHeights[column] = originalY; + targetHeights[column] = Math.max(originalY, Math.min( + area.maxY(), resolution.targetY())); + rigidBaseSupport[column] = resolution.rigidBaseSupport() + && targetHeights[column] > originalY; + } + } + for (int z = area.minZ(); z <= area.maxZ(); z++) { + for (int x = area.minX(); x <= area.maxX(); x++) { + int column = (z - area.minZ()) * width + x - area.minX(); + applySurfaceColumn(world, position, x, z, + originalHeights[column], targetHeights[column], + area.minY(), area.maxY(), rigidBaseSupport[column]); + } + } + } + + private static int resolveVacuumSurfaceHeight( + WorldGenLevel world, BlockPos.MutableBlockPos position, + int x, int z, int nominalY, int worldMinY) { + for (int y = nominalY; y >= worldMinY; y--) { + if (isTerrainBlock(world.getBlockState(position.set(x, y, z)))) { + return y; + } + } + return nominalY; + } + + private static int findVacuumFoundationSupport( + WorldGenLevel world, BoundingBox area, + BlockPos.MutableBlockPos position, int x, int startY, int z) { + if (startY < area.minY()) { + return Integer.MIN_VALUE; + } + int maximumY = Math.min(area.maxY(), startY); + for (int y = maximumY; y >= area.minY(); y--) { + BlockState state = world.getBlockState(position.set(x, y, z)); + if (isTerrainBlock(state)) { + return y; + } + if (!state.isAir() && state.getFluidState().isEmpty()) { + return Integer.MIN_VALUE; + } + } + return Integer.MIN_VALUE; + } + + private static boolean intersectsVacuumOccupancy( + Set occupiedCells, int x, int z, int minimumY, int maximumY) { + for (int y = minimumY; y <= maximumY; y++) { + if (occupiedCells.contains(BlockPos.asLong(x, y, z))) { + return true; + } + } + return false; + } + + private static SurfaceResolution resolveVacuumSurface( + VacuumFootprint footprint, int worldX, int worldZ, int originalY) { + long column = NativeStructureTemplateOccupancy.columnKey(worldX, worldZ); + Integer cap = footprint.columnCaps().get(column); + VacuumAnchor local = footprint.anchors().get(column); + if (local != null && local.strength() > 1) { + int localTargetY = cap == null + ? local.surfaceY() : Math.min(local.surfaceY(), cap); + if (localTargetY <= originalY) { + return new SurfaceResolution(originalY, false); + } + return new SurfaceResolution( + localTargetY, localTargetY == local.surfaceY()); + } + long totalInfluence = 0L; + long weightedSurfaceY = 0L; + long maximumInfluence = 0L; + int radiusSquared = SURFACE_TERRAIN_RADIUS * SURFACE_TERRAIN_RADIUS; + for (int offsetX = -SURFACE_TERRAIN_RADIUS; + offsetX <= SURFACE_TERRAIN_RADIUS; offsetX++) { + for (int offsetZ = -SURFACE_TERRAIN_RADIUS; + offsetZ <= SURFACE_TERRAIN_RADIUS; offsetZ++) { + int horizontalDistanceSquared = offsetX * offsetX + offsetZ * offsetZ; + if (horizontalDistanceSquared > radiusSquared) { + continue; + } + VacuumAnchor anchor = footprint.anchors().get( + NativeStructureTemplateOccupancy.columnKey( + worldX + offsetX, worldZ + offsetZ)); + if (anchor == null || anchor.surfaceY() <= originalY) { + continue; + } + int verticalDistance = Math.abs(anchor.surfaceY() + 1 - originalY); + long distanceSquared = horizontalDistanceSquared + + (long) verticalDistance * verticalDistance; + double factor = 0D; + if (distanceSquared <= radiusSquared) { + factor = surfaceFactor(Math.sqrt(distanceSquared)); + } + if (anchor.strength() > 1 + && verticalDistance > SURFACE_TERRAIN_RADIUS) { + double rescueProgress = Math.min(1D, + (verticalDistance - SURFACE_TERRAIN_RADIUS) + / (double) SURFACE_TERRAIN_RADIUS); + double rescueWeight = rescueProgress * rescueProgress + * (3D - 2D * rescueProgress); + factor = Math.max(factor, + surfaceFactor(Math.sqrt(horizontalDistanceSquared)) + * rescueWeight); + } + long influence = Math.round( + factor * SURFACE_TERRAIN_INFLUENCE_SCALE); + if (influence <= 0L) { + continue; + } + long weightedInfluence = influence * Math.max(1, anchor.strength()); + totalInfluence += weightedInfluence; + weightedSurfaceY += weightedInfluence * anchor.surfaceY(); + maximumInfluence = Math.max(maximumInfluence, influence); + } + } + if (totalInfluence == 0L) { + return new SurfaceResolution(originalY, false); + } + double blendedSurfaceY = weightedSurfaceY / (double) totalInfluence; + double factor = maximumInfluence / (double) SURFACE_TERRAIN_INFLUENCE_SCALE; + int targetY = (int) Math.round( + originalY + ((blendedSurfaceY - originalY) * factor)); + if (cap != null) { + targetY = Math.min(targetY, cap); + } + return new SurfaceResolution(Math.max(originalY, targetY), false); + } + + private static double surfaceFactor(double distance) { + return Math.pow(1D - distance / SURFACE_TERRAIN_RADIUS, + SURFACE_TERRAIN_FALLOFF); + } + static void applySurfaceColumn(WorldGenLevel world, BlockPos.MutableBlockPos position, int x, int z, int originalY, int targetY, int worldMinY, int worldMaxY) { @@ -405,4 +773,96 @@ public final class NativeStructureSurfaceFitter { private record SurfaceResolution(int targetY, boolean rigidBaseSupport) { } + + private record VacuumFootprint( + Map anchors, Map columnCaps, + Set foundationBases, Set occupiedCells) { + } + + private record VacuumAnchor(int surfaceY, int strength) { + } + + public static final class VacuumFoundationPlan { + private static final VacuumFoundationPlan EMPTY = + new VacuumFoundationPlan(List.of(), Set.of()); + + private final List foundationBases; + private final Set occupiedCells; + + private VacuumFoundationPlan( + List foundationBases, Set occupiedCells) { + this.foundationBases = foundationBases; + this.occupiedCells = occupiedCells; + } + + private static VacuumFoundationPlan empty() { + return EMPTY; + } + + private static VacuumFoundationPlan create( + Set packedBases, Set packedOccupancy, + BoundingBox area) { + if (packedBases.isEmpty()) { + return EMPTY; + } + List bases = new ArrayList<>(packedBases.size()); + Set columns = new HashSet<>(); + for (long packedBase : packedBases) { + BlockPos base = BlockPos.of(packedBase); + if (!area.isInside(base)) { + continue; + } + bases.add(packedBase); + columns.add(NativeStructureTemplateOccupancy.columnKey( + base.getX(), base.getZ())); + } + if (bases.isEmpty()) { + return EMPTY; + } + bases.sort((first, second) -> { + int yOrder = Integer.compare( + BlockPos.of(first).getY(), BlockPos.of(second).getY()); + return yOrder == 0 ? Long.compare(first, second) : yOrder; + }); + Set occupancy = new HashSet<>(); + for (long packedCell : packedOccupancy) { + BlockPos cell = BlockPos.of(packedCell); + if (columns.contains(NativeStructureTemplateOccupancy.columnKey( + cell.getX(), cell.getZ()))) { + occupancy.add(packedCell); + } + } + return new VacuumFoundationPlan( + List.copyOf(bases), Set.copyOf(occupancy)); + } + } + + private static final class VacuumCellBudget { + private final int maximum; + private int consumed; + + private VacuumCellBudget(int maximum) { + if (maximum < 0) { + throw new IllegalArgumentException( + "Native structure vacuum cell budget cannot be negative"); + } + this.maximum = maximum; + } + + private void consume(int amount) { + if (amount < 0 || consumed > maximum - amount) { + throw VacuumBudgetExceeded.INSTANCE; + } + consumed += amount; + } + } + + private static final class VacuumBudgetExceeded extends RuntimeException { + private static final VacuumBudgetExceeded INSTANCE = + new VacuumBudgetExceeded(); + + private VacuumBudgetExceeded() { + super(null, null, false, false); + } + } } 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 index 1cd1832fd..27e929c11 100644 --- 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 @@ -2,19 +2,12 @@ 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; @@ -31,7 +24,6 @@ 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() { } @@ -100,15 +92,22 @@ final class NativeStructureSurfaceSupportBuilder { 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(); + NativeStructureTemplateOccupancy.OccupancyResult occupancy = + NativeStructureTemplateOccupancy.resolve( + world, upper.piece(), referencePos, area, templates, + position -> retain(position, area, lowerMeets), budget::consume); + if (!occupancy.resolved()) { + continue; + } + Map lowest = + NativeStructureTemplateOccupancy.lowestCells(occupancy.cells()); + for (Map.Entry entry + : lowest.entrySet()) { + NativeStructureTemplateOccupancy.LowestCell cell = entry.getValue(); Integer lowerMeet = lowerMeets.get(entry.getKey()); if (lowerMeet == null || cell.occupancy().blocker() - || !isSolidBase(cell.occupancy().state()) + || !NativeStructureTemplateOccupancy.isSolidBase( + cell.occupancy().state()) || cell.y() - lowerMeet < 2 || cell.y() - lowerMeet > MAX_BRIDGE_SPAN) { continue; @@ -168,75 +167,6 @@ final class NativeStructureSurfaceSupportBuilder { 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)) { @@ -246,22 +176,6 @@ final class NativeStructureSurfaceSupportBuilder { 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) { @@ -338,13 +252,6 @@ final class NativeStructureSurfaceSupportBuilder { 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) @@ -365,19 +272,6 @@ final class NativeStructureSurfaceSupportBuilder { 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) { } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTemplateOccupancy.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTemplateOccupancy.java new file mode 100644 index 000000000..ba2682ca4 --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureTemplateOccupancy.java @@ -0,0 +1,147 @@ +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.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.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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +final class NativeStructureTemplateOccupancy { + private static final OccupancyCell BLOCKER = new OccupancyCell(null, true); + + private NativeStructureTemplateOccupancy() { + } + + static OccupancyResult resolve( + WorldGenLevel world, PoolElementStructurePiece piece, + BlockPos referencePosition, BoundingBox processingArea, + Supplier templates, + PositionFilter filter, CellBudget budget) { + List leaves = new ArrayList<>(); + if (!flattenSingles(piece.getElement(), leaves)) { + return new OccupancyResult(false, Map.of()); + } + List placements = new ArrayList<>(leaves.size()); + Map occupancy = new HashMap<>(); + for (SinglePoolElement leaf : leaves) { + StructurePlaceSettings settings = NativeStructureReflection.resolvePlacementSettings( + leaf, piece, processingArea); + 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 (filter.retain(position)) { + occupancy.put(position.asLong(), BLOCKER); + } + } + } + for (LeafPlacement placement : placements) { + List processed = + NativeStructureReflection.processTemplateBlocks( + world, piece.getPosition(), referencePosition, + placement.settings(), placement.rawBlocks(), placement.template()); + budget.consume(processed.size()); + for (StructureTemplate.StructureBlockInfo block : processed) { + BlockPos position = block.pos(); + if (!filter.retain(position)) { + continue; + } + BlockState state = block.state() + .mirror(placement.settings().getMirror()) + .rotate(placement.settings().getRotation()); + occupancy.put(position.asLong(), new OccupancyCell(state, false)); + } + } + return new OccupancyResult(true, occupancy); + } + + static Map lowestCells(Map occupancy) { + Map lowest = new HashMap<>(); + for (Map.Entry entry : occupancy.entrySet()) { + OccupancyCell cell = entry.getValue(); + 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(), cell)); + } + } + return lowest; + } + + static boolean isSolidBase(BlockState state) { + return state != null && state.isSolid() + && !state.is(Blocks.STRUCTURE_VOID) + && !state.is(Blocks.JIGSAW) + && state.getFluidState().isEmpty(); + } + + static long columnKey(int x, int z) { + return (long) x << 32 ^ z & 0xffffffffL; + } + + 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; + } + + @FunctionalInterface + interface PositionFilter { + boolean retain(BlockPos position); + } + + @FunctionalInterface + interface CellBudget { + void consume(int amount); + } + + record OccupancyResult(boolean resolved, Map cells) { + } + + record OccupancyCell(BlockState state, boolean blocker) { + } + + record LowestCell(int x, int y, int z, OccupancyCell occupancy) { + } + + private record LeafPlacement( + StructurePlaceSettings settings, + List rawBlocks, + StructureTemplate template) { + } +} 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 805b4368c..25fee3811 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 @@ -266,7 +266,6 @@ 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/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVegetationClearer.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVegetationClearer.java index b02999fd6..c08964c3d 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVegetationClearer.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVegetationClearer.java @@ -27,39 +27,36 @@ public final class NativeStructureVegetationClearer { || step == GenerationStep.Decoration.STRONGHOLDS; } - public static boolean shouldClearEntireVegetationFootprint(GenerationStep.Decoration step, - boolean configured) { - return configured; - } - public static void clearIntersectingVegetation(WorldGenLevel world, ChunkAccess chunk, BoundingBox area, - List targets) { - if (targets == null || targets.isEmpty()) { + List starts) { + if (starts == null || starts.isEmpty()) { return; } VegetationSnapshot snapshot = captureVegetation(chunk, area); if (snapshot.treeBlockCount() == 0) { return; } - boolean[] clearColumns = new boolean[area.getXSpan() * area.getZSpan()]; - for (VegetationTarget target : targets) { - if (shouldProcessTarget(target)) { - markVegetationColumns(area, snapshot, target, clearColumns); + int columns = area.getXSpan() * area.getZSpan(); + int[] clearMinY = new int[columns]; + int[] clearMaxY = new int[columns]; + Arrays.fill(clearMinY, Integer.MAX_VALUE); + Arrays.fill(clearMaxY, Integer.MIN_VALUE); + for (StructureStart start : starts) { + if (shouldProcessStart(start)) { + markVegetationColumns(area, snapshot, start, clearMinY, clearMaxY); } } - clearVegetationColumns(world, area, snapshot, clearColumns); + clearVegetationColumns(world, area, snapshot, clearMinY, clearMaxY); } - static boolean shouldProcessTarget(VegetationTarget target) { - return target != null && target.start() != null && target.start().isValid(); + static boolean shouldProcessStart(StructureStart start) { + return start != null && start.isValid(); } private static VegetationSnapshot captureVegetation(ChunkAccess chunk, BoundingBox area) { int width = area.getXSpan(); int depth = area.getZSpan(); BitSet[] columns = new BitSet[width * depth]; - int[] lowestY = new int[columns.length]; - Arrays.fill(lowestY, Integer.MAX_VALUE); int treeBlockCount = 0; LevelChunkSection[] sections = chunk.getSections(); int chunkMinX = chunk.getPos().getMinBlockX(); @@ -89,21 +86,22 @@ public final class NativeStructureVegetationClearer { columns[column] = treeBlocks; } treeBlocks.set(y - area.minY()); - lowestY[column] = Math.min(lowestY[column], y); treeBlockCount++; } } } } - return new VegetationSnapshot(columns, lowestY, treeBlockCount); + return new VegetationSnapshot(columns, treeBlockCount); } private static void markVegetationColumns(BoundingBox area, VegetationSnapshot snapshot, - VegetationTarget target, boolean[] clearColumns) { + StructureStart start, int[] clearMinY, int[] clearMaxY) { int width = area.getXSpan(); - int[] pieceTops = new int[clearColumns.length]; + int[] pieceTops = new int[clearMinY.length]; + int[] pieceBottoms = new int[clearMinY.length]; Arrays.fill(pieceTops, Integer.MIN_VALUE); - for (StructurePiece piece : target.start().getPieces()) { + Arrays.fill(pieceBottoms, Integer.MAX_VALUE); + for (StructurePiece piece : start.getPieces()) { BoundingBox bounds = piece.getBoundingBox(); int minX = Math.max(area.minX(), bounds.minX()); int maxX = Math.min(area.maxX(), bounds.maxX()); @@ -116,6 +114,7 @@ public final class NativeStructureVegetationClearer { for (int x = minX; x <= maxX; x++) { int column = (z - area.minZ()) * width + x - area.minX(); pieceTops[column] = Math.max(pieceTops[column], bounds.maxY()); + pieceBottoms[column] = Math.min(pieceBottoms[column], bounds.minY()); } } } @@ -123,18 +122,13 @@ public final class NativeStructureVegetationClearer { if (snapshot.columns()[column] == null || pieceTops[column] == Integer.MIN_VALUE) { continue; } - if (shouldClearVegetationColumn(pieceTops[column], snapshot.lowestY()[column], target.force())) { - clearColumns[column] = true; - } + clearMinY[column] = Math.min(clearMinY[column], pieceBottoms[column] - 1); + clearMaxY[column] = Math.max(clearMaxY[column], pieceTops[column]); } } - static boolean shouldClearVegetationColumn(int pieceTopY, int lowestTreeY, boolean force) { - return force || pieceTopY >= lowestTreeY; - } - private static void clearVegetationColumns(WorldGenLevel world, BoundingBox area, - VegetationSnapshot snapshot, boolean[] clearColumns) { + VegetationSnapshot snapshot, int[] clearMinY, int[] clearMaxY) { int width = area.getXSpan(); BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); BlockState air = Blocks.AIR.defaultBlockState(); @@ -142,10 +136,16 @@ public final class NativeStructureVegetationClearer { for (int x = area.minX(); x <= area.maxX(); x++) { int column = (z - area.minZ()) * width + x - area.minX(); BitSet treeBlocks = snapshot.columns()[column]; - if (!clearColumns[column] || treeBlocks == null) { + if (treeBlocks == null || clearMaxY[column] < clearMinY[column]) { continue; } - for (int bit = treeBlocks.nextSetBit(0); bit >= 0; bit = treeBlocks.nextSetBit(bit + 1)) { + int firstBit = Math.max(0, clearMinY[column] - area.minY()); + int lastBit = Math.min(area.getYSpan() - 1, clearMaxY[column] - area.minY()); + if (firstBit > lastBit) { + continue; + } + for (int bit = treeBlocks.nextSetBit(firstBit); bit >= 0 && bit <= lastBit; + bit = treeBlocks.nextSetBit(bit + 1)) { int y = area.minY() + bit; position.set(x, y, z); BlockState state = world.getBlockState(position); @@ -167,9 +167,6 @@ public final class NativeStructureVegetationClearer { || path.endsWith("_leaves"); } - public record VegetationTarget(StructureStart start, boolean force) { - } - - private record VegetationSnapshot(BitSet[] columns, int[] lowestY, int treeBlockCount) { + private record VegetationSnapshot(BitSet[] columns, int treeBlockCount) { } } 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 189556619..8180c4bcb 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 @@ -279,7 +279,7 @@ final class ModdedNativeStructureStage { Engine current = generator.engine(); List placementGroups = new ArrayList<>(); List heightmapStarts = new ArrayList<>(); - List vegetationTargets = new ArrayList<>(); + List vegetationTargets = new ArrayList<>(); List terrainTargets = new ArrayList<>(); for (int step = 0; step < steps; step++) { int index = 0; @@ -310,11 +310,7 @@ final class ModdedNativeStructureStage { structureId, start, NativeStructureTerrainIntegrator.resolveNativeTerrain( start, decision.terrain()))); - boolean clearEntireFootprint = NativeStructureVegetationClearer - .shouldClearEntireVegetationFootprint( - structure.step(), decision.clearVegetation()); - vegetationTargets.add(new NativeStructureVegetationClearer.VegetationTarget( - start, clearEntireFootprint)); + vegetationTargets.add(start); } if (!resolvedPlacements.isEmpty()) { placementGroups.add(new NativePlacementGroup( @@ -346,8 +342,9 @@ final class ModdedNativeStructureStage { "vegetation cleanup", nativeStructureBatchContext(placementGroups), chunkPos.x(), chunkPos.z(), error); } + NativeStructureSurfaceFitter.VacuumFoundationPlan vacuumFoundationPlan; try { - NativeStructureSurfaceFitter.prepareSurfaceStructures( + vacuumFoundationPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures( world, area, terrainTargets, (x, z) -> current.getHeight(x, z, true) + current.getMinHeight()); } catch (Throwable error) { @@ -375,6 +372,14 @@ final class ModdedNativeStructureStage { "placement", group.structureId(), chunkPos.x(), chunkPos.z(), error); } } + try { + NativeStructureSurfaceFitter.repairVacuumFoundations( + world, area, vacuumFoundationPlan); + } catch (Throwable error) { + throw NativeStructureGenerationException.failure( + "foundation repair", nativeStructureBatchContext(placementGroups), + chunkPos.x(), chunkPos.z(), error); + } } private static String nativeStructureBatchContext(List 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 089a71a40..85ec612cf 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 @@ -64,6 +64,7 @@ public class NativeStructureFailureContractTest { assertTrue(placement.contains("\"terrain integration\"")); assertTrue(placement.contains("\"terrain preparation\"")); + assertTrue(placement.contains("\"foundation repair\"")); assertFalse(placement.contains("\"terrain carving\"")); assertTrue(placement.contains("prepareSurfaceStructures")); assertTrue(placement.contains("clearIntersectingVegetation")); @@ -71,6 +72,8 @@ public class NativeStructureFailureContractTest { < placement.indexOf("prepareSurfaceStructures")); assertTrue(placement.indexOf("prepareSurfaceStructures") < placement.indexOf("for (NativePlacementGroup group")); + assertTrue(placement.indexOf("repairVacuumFoundations") + > placement.indexOf("for (NativePlacementGroup group")); } @Test diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java index 6a799d4d8..8e5a412b6 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java @@ -332,6 +332,6 @@ public class IrisModdedStructureCommandTest { } private IrisNativeStructureDecision decision(NativeStructureGenerationStatus status) { - return new IrisNativeStructureDecision(status, 0, null, false, false, null, null); + return new IrisNativeStructureDecision(status, 0, null, false, null, null); } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureReferenceRepairTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureReferenceRepairTest.java index 56f5d1e2f..03efa2d1a 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureReferenceRepairTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/nativegen/NativeStructureReferenceRepairTest.java @@ -102,13 +102,13 @@ public class NativeStructureReferenceRepairTest { public void deniedNaturalStartsCannotEnterCollisionArbitration() { IrisNativeStructureDecision enabled = new IrisNativeStructureDecision( NativeStructureGenerationStatus.GENERATE_NATIVE, - 0, null, false, false, null, new IrisStructureTerrain()); + 0, null, false, null, new IrisStructureTerrain()); IrisNativeStructureDecision disabled = new IrisNativeStructureDecision( NativeStructureGenerationStatus.DISABLED_BY_PACK, - 0, null, false, false, null, new IrisStructureTerrain()); + 0, null, false, null, new IrisStructureTerrain()); IrisNativeStructureDecision replaced = new IrisNativeStructureDecision( NativeStructureGenerationStatus.REPLACED_BY_IRIS, - 0, null, false, false, null, new IrisStructureTerrain()); + 0, null, false, null, new IrisStructureTerrain()); assertTrue(NativeStructureReferenceRepair.naturalDecisionAllows(enabled)); assertFalse(NativeStructureReferenceRepair.naturalDecisionAllows(disabled)); 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 index 22a4a5da5..6d0d365e9 100644 --- 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 @@ -20,7 +20,7 @@ import org.junit.Test; import java.util.List; import java.util.Map; -import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class NativeStructureVacuumParityTest { @@ -31,7 +31,7 @@ public class NativeStructureVacuumParityTest { } @Test - public void explicitVacuumUsesSharedThinSurfaceFittingOnModdedLoaders() { + public void explicitVacuumUsesNonCarvingSurfaceFittingOnModdedLoaders() { Structure structure = new DesertPyramidStructure( new Structure.StructureSettings( HolderSet.empty(), Map.of(), @@ -45,7 +45,7 @@ public class NativeStructureVacuumParityTest { new IrisStructureTerrain().setMode(IrisStructureTerrainMode.VACUUM)); assertTrue(NativeStructureSurfaceFitter.requiresSurfaceTerrain(target)); - assertEquals(TerrainAdjustment.BEARD_THIN, - NativeStructureSurfaceFitter.effectiveSurfaceAdjustment(target)); + assertFalse(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir( + start, target.terrain())); } } diff --git a/build.gradle b/build.gradle index aee8c41f3..bea54c9c2 100644 --- a/build.gradle +++ b/build.gradle @@ -73,11 +73,14 @@ String forgeArtifactName = irisArtifactName('Forge', "${minecraftVersion}+${load String neoForgeArtifactName = irisArtifactName('NeoForge', "${minecraftVersion}+${loaderDisplayVersion(neoForgeVersion)}") apply plugin: ApiGenerator -// Where `buildAll` drops the per-platform jars for a local test server. Defaults to a repo-local -// directory so the task works on a fresh clone; point it at a real server tree with -// `-Plocation=/path/to/consumers`. +// Where `buildAll` drops the per-platform jars for a local test server. Use the approved sibling +// Multiplexor workspace when it exists, with a repo-local fallback for fresh clones. +File multiplexorConsumerLocation = new File(rootDir, '../../[Minecraft Server]/consumers').canonicalFile +String defaultConsumerLocation = multiplexorConsumerLocation.isDirectory() + ? multiplexorConsumerLocation.absolutePath + : layout.buildDirectory.dir('consumers').get().asFile.absolutePath String consumerLocation = providers.gradleProperty('location') - .getOrElse(layout.buildDirectory.dir('consumers').get().asFile.absolutePath) + .getOrElse(defaultConsumerLocation) String bukkitConsumerPath = "${consumerLocation}/plugin-consumers/dropins/plugins" String fabricConsumerPath = "${consumerLocation}/fabric-mod-consumers/dropins/mods" String forgeConsumerPath = "${consumerLocation}/forge-mod-consumers/dropins/mods" @@ -180,11 +183,9 @@ tasks.register('buildBukkit', Copy) { } } -// The three loader jars come out of nested standalone Gradle builds driven through Exec, so Gradle -// cannot see through to their real inputs and re-runs all three on every buildAll. The declarations -// below give each Exec a truthful input set. Anything that feeds a loader jar has to be listed here, -// including the VolmLib checkout when source substitution is active - an unlisted source tree means -// an edit there is silently ignored and a stale jar is treated as up to date. +// The three loader jars come out of nested standalone Gradle builds driven through Exec. Their own +// Gradle builds own the complete incremental model, so always launch them instead of allowing an +// incomplete outer input inventory to publish stale jars. Closure resolveLocalVolmLibDirectory = { -> String configured = localVolmLibDirectory != null && !localVolmLibDirectory.isBlank() ? localVolmLibDirectory @@ -242,7 +243,7 @@ Closure declareModdedJarIo = { Exec task, String adapter, String artifactN task.inputs.property('volmLibCoordinate', volmLibCoordinate) task.inputs.property('localVolmLibCheckout', localVolmLibCheckout == null ? '' : localVolmLibCheckout.absolutePath) task.outputs.file(layout.projectDirectory.file("adapters/${adapter}/build/libs/${artifactName}")) - // The nested build owns its own caching; a remote cache entry here would be a lie. + task.outputs.upToDateWhen { false } task.outputs.cacheIf { false } return null } diff --git a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java index 266f03b3c..e6df63d36 100644 --- a/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java +++ b/core/src/main/java/art/arcane/iris/core/ServerConfigurator.java @@ -303,15 +303,19 @@ public class ServerConfigurator { return ""; } Path root = packsDir.toPath().toAbsolutePath().normalize(); - if (Files.isSymbolicLink(root)) { - throw new IllegalArgumentException("Iris packs root is a symbolic link: " + root); + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + return ""; } - if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isDirectory(root)) { + if (Files.isSymbolicLink(root)) { + throw new IllegalArgumentException("Iris packs root target is missing or unsafe: " + root); + } return ""; } try { + Path resolvedRoot = root.toRealPath(); MessageDigest digest = MessageDigest.getInstance("SHA-256"); - List entries = collectFingerprintEntries(root); + List entries = collectFingerprintEntries(resolvedRoot); entries.sort(Comparator.comparing(FingerprintEntry::relativePath)); byte[] buffer = new byte[8192]; for (FingerprintEntry entry : entries) { 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 22ffc198f..12509ae02 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 @@ -91,6 +91,7 @@ import java.util.stream.Stream; public final class DatapackIngestService { private static final String USER_AGENT = "VolmitSoftware/Iris (datapack-ingest)"; + private static final String FINDER_METADATA = ".DS_Store"; private static final String OVERRIDES_STRIPPED_MARKER = ".iris-overrides-stripped"; private static final String OWNERSHIP_MARKER = ".iris-managed.json"; private static final String TRANSACTION_DIRECTORY = ".iris-datapack-transactions"; @@ -803,6 +804,7 @@ public final class DatapackIngestService { if (!ownershipSourceMatches(ownership, entry)) { throw new IOException("Ownership marker at " + directory.getPath() + " belongs to '" + ownership.id + "'"); } + removeFinderMetadata(directory); if (!Objects.equals(ownership.contentHash, directoryHash(directory))) { throw new IOException("Refusing to remove modified or corrupt Iris-managed datapack " + directory.getPath()); } @@ -1479,6 +1481,9 @@ public final class DatapackIngestService { } validateInstallTree(target, worldFolder, "Existing datapack install"); Ownership ownership = readOwnershipOrNull(target); + if (ownership != null) { + removeFinderMetadata(target); + } String currentHash = directoryHash(target); originalHash = currentHash; originalMarkerHash = ownershipMarkerFingerprint(target); @@ -1705,6 +1710,9 @@ public final class DatapackIngestService { throw new IOException("Missing or unsafe " + purpose + " at " + directory.getPath()); } validateInstallTree(directory, storeAnchor, purpose); + if (Files.exists(new File(directory, OWNERSHIP_MARKER).toPath(), LinkOption.NOFOLLOW_LINKS)) { + removeFinderMetadata(directory); + } if (!expectedIdentity.isEmpty() && !Objects.equals(directoryIdentity(directory), expectedIdentity)) { throw new IOException("Datapack directory identity changed in " + purpose + " at " + directory.getPath()); @@ -1991,6 +1999,7 @@ public final class DatapackIngestService { if (!id.equals(ownership.id)) { throw new IOException("Datapack ownership mismatch at " + directory.getPath()); } + removeFinderMetadata(directory); } private static void rejectSymbolicLinks(File root) throws IOException { @@ -2019,6 +2028,13 @@ public final class DatapackIngestService { if (path.equals(rootMarker)) { continue; } + if (isFinderMetadata(path)) { + if (Files.isSymbolicLink(path) + || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Suspicious Finder metadata in datapack: " + path); + } + continue; + } pathCount++; if (pathCount > MAX_MANAGED_PATHS) { throw new IOException("Datapack contains more than " + MAX_MANAGED_PATHS + " paths"); @@ -2087,6 +2103,31 @@ public final class DatapackIngestService { } } + private static void removeFinderMetadata(File root) throws IOException { + List entries; + try (Stream paths = Files.walk(root.toPath())) { + entries = paths.limit(MAX_MANAGED_PATHS + 1L).toList(); + } + if (entries.size() > MAX_MANAGED_PATHS) { + throw new IOException("Datapack contains more than " + MAX_MANAGED_PATHS + " paths"); + } + for (Path path : entries) { + if (!isFinderMetadata(path)) { + continue; + } + if (Files.isSymbolicLink(path) + || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Suspicious Finder metadata in datapack: " + path); + } + Files.delete(path); + } + } + + private static boolean isFinderMetadata(Path path) { + Path fileName = path.getFileName(); + return fileName != null && FINDER_METADATA.equals(fileName.toString()); + } + private static PackResources scanPackResources(File root) throws IOException { TreeSet structureKeys = new TreeSet<>(); TreeSet templateKeys = new TreeSet<>(); @@ -2133,6 +2174,7 @@ public final class DatapackIngestService { if (!id.equals(ownership.id)) { throw new IOException("Ownership marker belongs to '" + ownership.id + "'"); } + removeFinderMetadata(directory); if (!Objects.equals(ownership.contentHash, directoryHash(directory))) { throw new IOException("Refusing to delete modified or corrupt Iris-managed datapack " + directory.getPath()); } @@ -3440,7 +3482,7 @@ public final class DatapackIngestService { } private static boolean isHarmlessRecoveryArtifact(Path path) throws IOException { - if (!".DS_Store".equals(path.getFileName().toString())) { + if (!isFinderMetadata(path)) { return false; } if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java index 758a9a48a..b2a3dfd86 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDimensionValidator.java @@ -44,7 +44,7 @@ final class PackDimensionValidator { continue; } - validateImportedStructurePolicy(dimensionKey, dimJson, blockingErrors); + validateImportedStructurePolicy(dimensionKey, dimJson, blockingErrors, warnings); JSONArray regionsArray = dimJson.optJSONArray("regions"); if (regionsArray == null || regionsArray.length() == 0) { @@ -90,7 +90,7 @@ final class PackDimensionValidator { } static void validateImportedStructurePolicy(String dimensionKey, JSONObject dimension, - List blockingErrors) { + List blockingErrors, List warnings) { if (!dimension.has("importedStructures")) { return; } @@ -127,6 +127,10 @@ final class PackDimensionValidator { + "' importedStructures.adjustments has a non-object entry at index " + index + "."); continue; } + if (adjustment.has("clearVegetation")) { + warnings.add("Dimension '" + dimensionKey + "' importedStructures.adjustments[" + index + + "].clearVegetation was removed and is ignored. Vegetation is always cleared inside structure piece envelopes."); + } validateStructureKeyList(dimensionKey, adjustment, "match", blockingErrors); validateAdjustmentYBand(dimensionKey, adjustment, index, blockingErrors); PackStructurePlacementValidator.validateNativeTerrain("Dimension '" + dimensionKey diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDirectoryResolver.java b/core/src/main/java/art/arcane/iris/core/pack/PackDirectoryResolver.java index 2d8dcef65..9a2a1f0e3 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDirectoryResolver.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDirectoryResolver.java @@ -47,7 +47,7 @@ public final class PackDirectoryResolver { if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { return List.of(); } - if (Files.isSymbolicLink(root) || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isDirectory(root)) { throw new IOException("Pack workspace is missing or unsafe: " + root); } try (Stream stream = Files.list(root)) { 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 758dcf3c9..ed6a4acbc 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 @@ -34,7 +34,7 @@ public record NativeStructureOwnershipRecord( String contentFingerprint, DecisionSnapshot decision ) { - public static final int CURRENT_SCHEMA = 1; + public static final int CURRENT_SCHEMA = 2; public static final int MAX_REFERENCE_DISTANCE_CHUNKS = 8; private static final int MAX_KEY_BYTES = 512; private static final int MAX_FINGERPRINT_BYTES = 128; @@ -251,7 +251,7 @@ public record NativeStructureOwnershipRecord( } } - public record DecisionSnapshot(boolean clearVegetation, String stiltJson, String terrainJson) { + public record DecisionSnapshot(String stiltJson, String terrainJson) { private static final Gson GSON = new Gson(); private static final int MAX_JSON_BYTES = 65_536; @@ -269,7 +269,6 @@ public record NativeStructureOwnershipRecord( throw new IllegalArgumentException("Only generated native structure decisions can be persisted"); } return new DecisionSnapshot( - resolved.clearVegetation(), GSON.toJson(resolved.stilt()), GSON.toJson(Objects.requireNonNullElseGet( resolved.terrain(), IrisStructureTerrain::new)) @@ -288,21 +287,18 @@ public record NativeStructureOwnershipRecord( 0, null, false, - clearVegetation, stilt, terrain ); } void write(DataOutputStream output) throws IOException { - output.writeBoolean(clearVegetation); writeString(output, stiltJson, MAX_JSON_BYTES, "stilt snapshot"); writeString(output, terrainJson, MAX_JSON_BYTES, "terrain snapshot"); } static DecisionSnapshot read(DataInputStream input) throws IOException { return new DecisionSnapshot( - input.readBoolean(), readString(input, MAX_JSON_BYTES, "stilt snapshot"), readString(input, MAX_JSON_BYTES, "terrain snapshot") ); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java index 6df472f24..502e5cad3 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java @@ -79,7 +79,6 @@ public final class NativeStructurePlacementPlanner { 0, null, false, - !placement.isUnderground(), placement.getStilt(), placement.resolvedTerrain() ); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java new file mode 100644 index 000000000..0f890d5f1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java @@ -0,0 +1,261 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.mantle.components; + +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.math.PowerOfTwoCoordinates; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.volmlib.util.matter.MatterSlice; + +import java.util.Arrays; + +public final class CarveOrphanSweep { + private static final int CHUNK_SIZE = 16; + private static final int CHUNK_AREA = CHUNK_SIZE * CHUNK_SIZE; + private static final int BAND_FLOOR_MARGIN = 4; + private static final int MAX_ORPHAN_CELLS = 16; + private static final MatterCavern ORPHAN_CAVERN = new MatterCavern(true, "", (byte) 0); + private static final ThreadLocal SCRATCH = ThreadLocal.withInitial(SweepScratch::new); + + public interface CarveAccess { + boolean isCarved(int localX, int y, int localZ); + + void markCarved(int localX, int y, int localZ); + } + + private CarveOrphanSweep() { + } + + static int sweepChunk(MantleChunk chunk, int[] surfaceHeights, int maxSurfaceBreakDepth, int worldCeilingY) { + if (chunk == null) { + return 0; + } + + return sweep(surfaceHeights, maxSurfaceBreakDepth, 0, worldCeilingY, new MantleCarveAccess(chunk)); + } + + public static int sweep(int[] surfaceHeights, int maxSurfaceBreakDepth, int worldFloorY, int worldCeilingY, CarveAccess access) { + if (surfaceHeights == null || surfaceHeights.length < CHUNK_AREA || access == null) { + return 0; + } + + int minSurfaceY = Integer.MAX_VALUE; + int maxSurfaceY = Integer.MIN_VALUE; + for (int columnIndex = 0; columnIndex < CHUNK_AREA; columnIndex++) { + int surfaceY = surfaceHeights[columnIndex]; + if (surfaceY < minSurfaceY) { + minSurfaceY = surfaceY; + } + if (surfaceY > maxSurfaceY) { + maxSurfaceY = surfaceY; + } + } + + int bandTop = Math.min(worldCeilingY, maxSurfaceY); + int bandFloor = Math.max(worldFloorY + 1, minSurfaceY - Math.max(0, maxSurfaceBreakDepth) - BAND_FLOOR_MARGIN); + if (bandTop < bandFloor) { + return 0; + } + + int bandHeight = bandTop - bandFloor + 1; + int cellCount = bandHeight * CHUNK_AREA; + SweepScratch scratch = SCRATCH.get(); + scratch.prepare(cellCount); + long[] solid = scratch.solid; + long[] visited = scratch.visited; + int[] stack = scratch.stack; + int[] component = scratch.component; + + boolean carvedPresent = false; + for (int y = bandFloor; y <= bandTop; y++) { + int layer = (y - bandFloor) * CHUNK_AREA; + for (int localX = 0; localX < CHUNK_SIZE; localX++) { + for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) { + int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); + if (y > surfaceHeights[columnIndex]) { + continue; + } + if (access.isCarved(localX, y, localZ)) { + carvedPresent = true; + continue; + } + + setBit(solid, layer + columnIndex); + } + } + } + + if (!carvedPresent) { + return 0; + } + + int marked = 0; + for (int rootIndex = 0; rootIndex < cellCount; rootIndex++) { + if (!getBit(solid, rootIndex) || getBit(visited, rootIndex)) { + continue; + } + + setBit(visited, rootIndex); + stack[0] = rootIndex; + int stackSize = 1; + int componentSize = 0; + boolean anchored = false; + + while (stackSize > 0) { + int current = stack[--stackSize]; + int columnIndex = current & (CHUNK_AREA - 1); + int localX = columnIndex >>> 4; + int localZ = columnIndex & (CHUNK_SIZE - 1); + int y = bandFloor + (current / CHUNK_AREA); + + if (!anchored) { + if (localX == 0 || localX == CHUNK_SIZE - 1 || localZ == 0 || localZ == CHUNK_SIZE - 1) { + anchored = true; + } else if (y == bandFloor && isSolidBelowBand(access, surfaceHeights, columnIndex, localX, localZ, bandFloor - 1, worldFloorY)) { + anchored = true; + } else if (componentSize >= MAX_ORPHAN_CELLS) { + anchored = true; + } else { + component[componentSize++] = current; + } + } + + if (y > bandFloor) { + stackSize = push(solid, visited, stack, stackSize, current - CHUNK_AREA); + } + if (y < bandTop) { + stackSize = push(solid, visited, stack, stackSize, current + CHUNK_AREA); + } + if (localX > 0) { + stackSize = push(solid, visited, stack, stackSize, current - CHUNK_SIZE); + } + if (localX < CHUNK_SIZE - 1) { + stackSize = push(solid, visited, stack, stackSize, current + CHUNK_SIZE); + } + if (localZ > 0) { + stackSize = push(solid, visited, stack, stackSize, current - 1); + } + if (localZ < CHUNK_SIZE - 1) { + stackSize = push(solid, visited, stack, stackSize, current + 1); + } + } + + if (anchored) { + continue; + } + + for (int index = 0; index < componentSize; index++) { + int cell = component[index]; + int columnIndex = cell & (CHUNK_AREA - 1); + access.markCarved(columnIndex >>> 4, bandFloor + (cell / CHUNK_AREA), columnIndex & (CHUNK_SIZE - 1)); + marked++; + } + } + + return marked; + } + + private static int push(long[] solid, long[] visited, int[] stack, int stackSize, int neighbor) { + if (!getBit(solid, neighbor) || getBit(visited, neighbor)) { + return stackSize; + } + + setBit(visited, neighbor); + stack[stackSize] = neighbor; + return stackSize + 1; + } + + private static boolean isSolidBelowBand(CarveAccess access, int[] surfaceHeights, int columnIndex, int localX, int localZ, int belowY, int worldFloorY) { + if (belowY < worldFloorY || belowY > surfaceHeights[columnIndex]) { + return false; + } + + return !access.isCarved(localX, belowY, localZ); + } + + private static boolean getBit(long[] bits, int index) { + return (bits[index >>> 6] & (1L << (index & 63))) != 0L; + } + + private static void setBit(long[] bits, int index) { + bits[index >>> 6] |= 1L << (index & 63); + } + + private static final class MantleCarveAccess implements CarveAccess { + private final MantleChunk chunk; + private MatterSlice cachedSlice; + private int cachedSectionIndex = -1; + + private MantleCarveAccess(MantleChunk chunk) { + this.chunk = chunk; + } + + @Override + public boolean isCarved(int localX, int y, int localZ) { + MatterSlice cavernSlice = resolveSlice(y >> 4); + if (cavernSlice == null) { + return false; + } + + MatterCavern cavern = cavernSlice.get(localX, y & 15, localZ); + return cavern != null && cavern.isCavern(); + } + + @Override + public void markCarved(int localX, int y, int localZ) { + chunk.getOrCreate(y >> 4).slice(MatterCavern.class).set(localX, y & 15, localZ, ORPHAN_CAVERN); + cachedSectionIndex = -1; + cachedSlice = null; + } + + private MatterSlice resolveSlice(int sectionIndex) { + if (sectionIndex == cachedSectionIndex) { + return cachedSlice; + } + + Matter section = sectionIndex >= 0 && sectionIndex < chunk.sectionCount() ? chunk.get(sectionIndex) : null; + cachedSlice = section == null ? null : section.getSlice(MatterCavern.class); + cachedSectionIndex = sectionIndex; + return cachedSlice; + } + } + + private static final class SweepScratch { + private final int[] component = new int[MAX_ORPHAN_CELLS]; + private long[] solid = new long[0]; + private long[] visited = new long[0]; + private int[] stack = new int[0]; + + private void prepare(int cellCount) { + int words = (cellCount + 63) >>> 6; + if (solid.length < words) { + solid = new long[words]; + visited = new long[words]; + } else { + Arrays.fill(solid, 0, words, 0L); + Arrays.fill(visited, 0, words, 0L); + } + + if (stack.length < cellCount) { + stack = new int[cellCount]; + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java index 25856b1a3..03a3c576c 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java @@ -46,7 +46,6 @@ public class IrisCaveCarver3D { private static final byte LIQUID_FORCED_AIR = 3; private static final int ADAPTIVE_MIN_PLANE_COLUMNS = 16; private static final int ADAPTIVE_DEEP_SAMPLE_STEP = 8; - private static final int ADAPTIVE_DEEP_SURFACE_MARGIN = 12; private static final double ADAPTIVE_LOCAL_RANGE_SCALE = 0.125D; private static final double ADAPTIVE_DEEP_MARGIN_BOOST = 0.015D; @@ -586,6 +585,12 @@ public class IrisCaveCarver3D { boolean[] planeCarve = scratch.planeCarve; int minSection = PowerOfTwoCoordinates.floorDivPow2(minY, 4); int maxSection = PowerOfTwoCoordinates.floorDivPow2(maxY, 4); + int effectiveAdaptiveSampleStep = Math.max(adaptiveSampleStep, ADAPTIVE_DEEP_SAMPLE_STEP); + double effectiveAdaptiveThresholdMargin = resolveAdaptivePlaneThresholdMargin( + adaptiveThresholdMargin, + adaptiveSampleStep, + effectiveAdaptiveSampleStep + ); for (int sectionIndex = minSection; sectionIndex <= maxSection; sectionIndex++) { int sectionMinY = Math.max(minY, PowerOfTwoCoordinates.chunkToBlock(sectionIndex)); @@ -614,12 +619,6 @@ public class IrisCaveCarver3D { continue; } - int effectiveAdaptiveSampleStep = resolveAdaptivePlaneSampleStep(y, adaptiveSampleStep); - double effectiveAdaptiveThresholdMargin = resolveAdaptivePlaneThresholdMargin( - adaptiveThresholdMargin, - adaptiveSampleStep, - effectiveAdaptiveSampleStep - ); classifyDensityPlaneAdaptive( scratch, x0, @@ -678,16 +677,6 @@ public class IrisCaveCarver3D { return carved; } - private int resolveAdaptivePlaneSampleStep(int y, int adaptiveSampleStep) { - if (adaptiveSampleStep >= ADAPTIVE_DEEP_SAMPLE_STEP) { - return adaptiveSampleStep; - } - - int profileMaxY = (int) Math.ceil(profile.getVerticalRange().getMax()); - int fineBandFloorY = profileMaxY - profile.getSurfaceBreakDepth() - ADAPTIVE_DEEP_SURFACE_MARGIN; - return y >= fineBandFloorY ? adaptiveSampleStep : ADAPTIVE_DEEP_SAMPLE_STEP; - } - private double resolveAdaptivePlaneThresholdMargin( double adaptiveThresholdMargin, int adaptiveSampleStep, diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java index f03cde4b9..1aa0f44a4 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java @@ -101,6 +101,23 @@ public class MantleCarvingComponent extends IrisMantleComponent { carveUpperTerrain(upperCtx, weightedProfiles, writer, x, z, chunkSurfaceHeights, waterSupportPlan); } waterSupportPlan.resolve(writer.acquireChunk(x, z)); + + if (!weightedProfiles.isEmpty()) { + CarveOrphanSweep.sweepChunk( + writer.acquireChunk(x, z), + chunkSurfaceHeights, + maxSurfaceBreakDepth(weightedProfiles), + writer.getMantle().getWorldHeight() - 1 + ); + } + } + + private static int maxSurfaceBreakDepth(List weightedProfiles) { + int maxDepth = 0; + for (WeightedProfile weightedProfile : weightedProfiles) { + maxDepth = Math.max(maxDepth, Math.max(0, weightedProfile.profile.getSurfaceBreakDepth())); + } + return maxDepth; } @ChunkCoordinates diff --git a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java index b70772e7e..43fadf3e2 100644 --- a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java +++ b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java @@ -100,10 +100,8 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { sGenMatter, sTerrain )); - registerStage(burst( - sCave, - sPost - )); + registerStage(sCave); + registerStage(sPost); registerStage(sFloatingTerrainSolid); registerStage(burst( sDeposit, diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisCaveProfile.java b/core/src/main/java/art/arcane/iris/engine/object/IrisCaveProfile.java index 715618482..bc94024fa 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisCaveProfile.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisCaveProfile.java @@ -81,16 +81,6 @@ public class IrisCaveProfile { @Desc("Additional adaptive ambiguity margin used before the cave predictor falls back to exact sampling.") private double adaptiveThresholdMargin = 0.04; - @MinNumber(0) - @MaxNumber(4096) - @Desc("Minimum carved cells expected from this profile before recovery boost applies.") - private int minCarveCells = 0; - - @MinNumber(0) - @MaxNumber(1) - @Desc("Additional threshold boost used when profile carve output is too sparse.") - private double recoveryThresholdBoost = 0.08; - @MinNumber(0) @MaxNumber(64) @Desc("Minimum solid clearance below terrain surface where carving may occur.") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java index 3f094fb9f..226926521 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java @@ -52,7 +52,7 @@ public class IrisImportedStructureControl { private boolean datapackOverrides = true; @ArrayType(type = IrisVanillaStructureAdjustment.class, min = 1) - @Desc("Per-structure adjustments applied to vanilla, mod, and datapack structures that still generate natively. Vertical shifts from every matching entry stack. A matching vegetation option explicitly clears logs and leaves inside structure piece bounds. A matching preserveSourceY option disables Iris burial repositioning for that structure. The last matching entry with stilt settings controls foundation columns, and likewise for terrain and yBand settings. A structure suppressed by an Iris placement is unaffected.") + @Desc("Per-structure adjustments applied to vanilla, mod, and datapack structures that still generate natively. Vertical shifts from every matching entry stack. A matching preserveSourceY option disables Iris burial repositioning for that structure. The last matching entry with stilt settings controls foundation columns, and likewise for terrain and yBand settings. A structure suppressed by an Iris placement is unaffected.") private KList adjustments = new KList<>(); public boolean shouldGenerate(String key) { @@ -64,7 +64,6 @@ public class IrisImportedStructureControl { adjustments, "importedStructures.adjustments must not be null"); int y = undergroundStep ? undergroundYShift : 0; boolean preserveSourceY = false; - boolean clearVegetation = false; IrisStructureStiltSettings stilt = null; IrisStructureTerrain terrain = null; IrisStructureYBand yBand = null; @@ -72,7 +71,6 @@ public class IrisImportedStructureControl { if (adjustment != null && adjustment.matches(key)) { y += adjustment.getYShift(); preserveSourceY |= adjustment.isPreserveSourceY(); - clearVegetation |= adjustment.isClearVegetation(); if (adjustment.getStilt() != null) { stilt = adjustment.getStilt(); } @@ -85,7 +83,7 @@ public class IrisImportedStructureControl { } } return new IrisNativeStructureDecision( - generationStatus(key), y, yBand, preserveSourceY, clearVegetation, stilt, terrain); + generationStatus(key), y, yBand, preserveSourceY, stilt, terrain); } private NativeStructureGenerationStatus generationStatus(String key) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisNativeStructureDecision.java b/core/src/main/java/art/arcane/iris/engine/object/IrisNativeStructureDecision.java index 0ce68c353..33a8bb34c 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisNativeStructureDecision.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisNativeStructureDecision.java @@ -23,7 +23,6 @@ public record IrisNativeStructureDecision( int yShift, IrisStructureYBand yBand, boolean preserveSourceY, - boolean clearVegetation, IrisStructureStiltSettings stilt, IrisStructureTerrain terrain ) { @@ -33,6 +32,6 @@ public record IrisNativeStructureDecision( public IrisNativeStructureDecision withStatus(NativeStructureGenerationStatus replacement) { return new IrisNativeStructureDecision( - replacement, yShift, yBand, preserveSourceY, clearVegetation, stilt, terrain); + replacement, yShift, yBand, preserveSourceY, stilt, terrain); } } 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 5ad2fdf31..bddedbb26 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,7 +22,7 @@ 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. 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.") + @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 raises terrain from processed rigid-template foundations at or below each authored ground plane with a 12-block falloff without lowering existing ground. 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) 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 d4a061950..4e3ee5a2c 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 @@ -9,7 +9,7 @@ public enum IrisStructureTerrainMode { 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.") + @Desc("Raises surface terrain from processed solid rigid-template foundations at or below each authored ground plane with a 12-block falloff, even when the registered structure has no terrain adaptation. Existing higher terrain and authored air remain untouched.") 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 65f45c6b5..708ff1656 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 @@ -32,7 +32,7 @@ import lombok.experimental.Accessors; @Accessors(chain = true) @NoArgsConstructor @AllArgsConstructor -@Desc("A per-structure adjustment applied to vanilla, mod, and datapack structures that still generate natively (those NOT suppressed by an Iris 'structures' placement). Vertical shifts move the structure start, pieces, bounds, and jigsaw metadata together before references and placement. Surface structures clear intersecting trees automatically; optional postprocessing can force vegetation clearing or build palette-driven foundation columns.") +@Desc("A per-structure adjustment applied to vanilla, mod, and datapack structures that still generate natively (those NOT suppressed by an Iris 'structures' placement). Vertical shifts move the structure start, pieces, bounds, and jigsaw metadata together before references and placement. Intersecting trees are cleared automatically inside structure piece envelopes; optional postprocessing can build palette-driven foundation columns.") @Data public class IrisVanillaStructureAdjustment { @ArrayType(type = String.class, min = 1) @@ -51,13 +51,10 @@ public class IrisVanillaStructureAdjustment { @Desc("When true, skip Iris burial repositioning so the structure keeps the Y its own vanilla placement chose. Underground structures are otherwise pushed below the lowest solid column across their whole footprint, which hides terrain-aware structures such as mineshafts that intentionally breach cliffs and surfaces. Vertical shifts still apply on top of the preserved Y: this dimension's undergroundYShift plus every matching adjustment's yShift.") private boolean preserveSourceY = false; - @Desc("When true, force logs and leaves out of the structure footprint even when the structure does not reach the detected tree base. Normal surface-intersecting structures are protected automatically.") - private boolean clearVegetation = false; - @Desc("Optional foundation columns placed beneath the native structure piece bases after placement.") private IrisStructureStiltSettings stilt = null; - @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.") + @Desc("Optional terrain integration override. VACUUM raises surface terrain from processed solid rigid-template foundations at or below each authored ground plane with a 12-block falloff without lowering existing ground. 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/IrisDatapackCompilerTest.java b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java index 23339e590..20d3423ff 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisDatapackCompilerTest.java @@ -8,6 +8,7 @@ import org.junit.Assume; import org.junit.rules.TemporaryFolder; import java.io.File; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -81,6 +82,23 @@ public class IrisDatapackCompilerTest { assertTrue(Files.isRegularFile(datapackRoot.resolve("pack.mcmeta"))); } + @Test + public void collectsInstalledPacksThroughSymbolicLinkWorkspace() throws Exception { + Path dataDirectory = temporaryFolder.newFolder("linked-data").toPath(); + Path serverRoot = temporaryFolder.newFolder("linked-server").toPath(); + Path sharedPacks = temporaryFolder.newFolder("linked-shared-packs").toPath(); + Path overworld = sharedPacks.resolve("overworld"); + createPack(overworld, "overworld", "linked_custom"); + try { + Files.createSymbolicLink(dataDirectory.resolve("packs"), sharedPacks); + } catch (IOException | UnsupportedOperationException | SecurityException exception) { + Assume.assumeNoException(exception); + } + + assertEquals(List.of(dataDirectory.resolve("packs/overworld").toFile()), + IrisDatapackCompiler.collectPackRoots(dataDirectory, serverRoot)); + } + @Test public void compilingNoPacksPublishesCleanEmptyDatapack() throws Exception { Path datapackRoot = temporaryFolder.newFolder("empty-datapack").toPath(); diff --git a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java index d6d2b6577..b7ee785d0 100644 --- a/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java +++ b/core/src/test/java/art/arcane/iris/core/ServerConfiguratorDatapackFingerprintTest.java @@ -148,6 +148,46 @@ public class ServerConfiguratorDatapackFingerprintTest { assertNotEquals(before, ServerConfigurator.computePackFingerprint(packsDir)); } + @Test + public void computePackFingerprintReadsSafeSymbolicWorkspaceRoots() throws Exception { + Path workspace = tmp.newFolder("pack-workspace").toPath(); + Path externalPack = tmp.newFolder("workspace-linked-pack").toPath(); + Path dimension = externalPack.resolve("dimensions/overworld.json"); + Files.createDirectories(dimension.getParent()); + Files.writeString(dimension, "first", StandardCharsets.UTF_8); + Path packLink = workspace.resolve("overworld"); + Path workspaceLink = tmp.getRoot().toPath().resolve("packs-link"); + try { + Files.createSymbolicLink(packLink, externalPack); + Files.createSymbolicLink(workspaceLink, workspace); + } catch (IOException | UnsupportedOperationException | SecurityException exception) { + Assume.assumeNoException(exception); + } + String before = ServerConfigurator.computePackFingerprint(workspaceLink.toFile()); + assertEquals(ServerConfigurator.computePackFingerprint(workspace.toFile()), before); + + Files.writeString(dimension, "other", StandardCharsets.UTF_8); + + assertNotEquals(before, ServerConfigurator.computePackFingerprint(workspaceLink.toFile())); + } + + @Test + public void computePackFingerprintRejectsDanglingSymbolicWorkspaceRoots() throws Exception { + Path workspaceLink = tmp.getRoot().toPath().resolve("dangling-packs-link"); + try { + Files.createSymbolicLink(workspaceLink, tmp.getRoot().toPath().resolve("missing-workspace")); + } catch (IOException | UnsupportedOperationException | SecurityException exception) { + Assume.assumeNoException(exception); + } + + try { + ServerConfigurator.computePackFingerprint(workspaceLink.toFile()); + fail("Dangling symbolic workspace roots must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("missing or unsafe")); + } + } + @Test public void incompleteExternalDatapackRecoveryBlocksCompilation() throws Exception { String source = Files.readString(Path.of( 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 3a4859dd5..492b69790 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 @@ -449,6 +449,39 @@ public class DatapackIngestServiceTest { assertEquals(List.of("original:template"), entry.templateKeys); } + @Test + public void finderMetadataDoesNotInvalidateManagedStaging() throws Exception { + File staging = datapackDirectory("finder-metadata-staging"); + File nested = new File(staging, "data/example"); + assertTrue(nested.mkdirs()); + DatapackIngestService.Entry entry = entry("finder-metadata-staging", "v1", "1", "sha"); + DatapackIngestService.writeOwnership(staging, entry); + File rootMetadata = new File(staging, ".DS_Store"); + File nestedMetadata = new File(nested, ".DS_Store"); + Files.writeString(rootMetadata.toPath(), "finder", StandardCharsets.UTF_8); + Files.writeString(nestedMetadata.toPath(), "finder", StandardCharsets.UTF_8); + + assertTrue(DatapackIngestService.isUsableStaging(staging, entry)); + assertFalse(rootMetadata.exists()); + assertFalse(nestedMetadata.exists()); + } + + @Test + public void finderMetadataDirectoryCannotBypassManagedHashing() throws Exception { + File staging = datapackDirectory("finder-metadata-directory"); + assertTrue(new File(staging, ".DS_Store").mkdir()); + DatapackIngestService.Entry entry = entry("finder-metadata-directory", "v1", "1", "sha"); + + try { + DatapackIngestService.writeOwnership(staging, entry); + fail("Expected suspicious Finder metadata to be rejected"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("Suspicious Finder metadata")); + } + + assertFalse(new File(staging, ".iris-managed.json").exists()); + } + @Test public void nestedOwnershipNamedResourceRemainsInsideTheManagedHash() throws Exception { File staging = datapackDirectory("nested-ownership-resource"); @@ -743,7 +776,7 @@ public class DatapackIngestServiceTest { DatapackIngestService.InstallPlan plan = prepareLegacyStagingPlan(fixture); DatapackIngestService.publishInstallPlan(plan); - assertTrue(plan.contentChanged()); + assertFalse(plan.contentChanged()); assertTrue(DatapackIngestService.freshInstallRequiresRestart(plan.contentChanged(), true)); assertEquals("same", Files.readString( new File(fixture.target(), "value.txt").toPath(), StandardCharsets.UTF_8)); @@ -1566,6 +1599,80 @@ public class DatapackIngestServiceTest { assertFalse(transaction.exists()); } + @Test + public void publishingInstallCrashRemovesFinderMetadataBeforeRollback() throws Exception { + File root = temporaryFolder.newFolder("finder-install-crash-rollback-root"); + File world = temporaryFolder.newFolder("finder-install-crash-rollback-world"); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + writeManifest(root, entry); + + File target = new File(world, entry.id); + writeManagedDatapack(target, entry, "old"); + String originalHash = ownershipHash(target); + File scratch = new File(world.getParentFile(), ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File pending = new File(scratch, "managed-pending-finder"); + File backup = new File(scratch, "managed-backup-finder"); + writeManagedDatapack(pending, entry, "new"); + assertTrue(new File(pending, "data/nova_structures").mkdirs()); + DatapackIngestService.writeOwnership(pending, entry); + String desiredHash = ownershipHash(pending); + Files.move(target.toPath(), backup.toPath()); + Files.move(pending.toPath(), target.toPath()); + Map directory = installDirectory( + target, pending, backup, true, originalHash, desiredHash); + File transaction = writeCoordinator(root, "INSTALL", "PUBLISHING", entry, true, + List.of(directory), List.of()); + Files.writeString(new File(target, ".DS_Store").toPath(), "finder", StandardCharsets.UTF_8); + Files.writeString(new File(target, "data/.DS_Store").toPath(), "finder", StandardCharsets.UTF_8); + Files.writeString(new File(target, "data/nova_structures/.DS_Store").toPath(), + "finder", StandardCharsets.UTF_8); + + DatapackIngestService.recoverTransactions(root, List.of(world)); + + assertEquals("old", Files.readString(new File(target, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertFalse(new File(target, ".DS_Store").exists()); + assertFalse(backup.exists()); + assertFalse(transaction.exists()); + } + + @Test + public void publishingInstallCrashStillRejectsAuthoredContentMutation() throws Exception { + File root = temporaryFolder.newFolder("changed-install-crash-rollback-root"); + File world = temporaryFolder.newFolder("changed-install-crash-rollback-world"); + DatapackIngestService.Entry entry = entry("managed", "v1", "1", "sha"); + writeManifest(root, entry); + + File target = new File(world, entry.id); + writeManagedDatapack(target, entry, "old"); + String originalHash = ownershipHash(target); + File scratch = new File(world.getParentFile(), ".iris-datapack-install"); + assertTrue(scratch.mkdirs()); + File pending = new File(scratch, "managed-pending-changed"); + File backup = new File(scratch, "managed-backup-changed"); + writeManagedDatapack(pending, entry, "new"); + String desiredHash = ownershipHash(pending); + Files.move(target.toPath(), backup.toPath()); + Files.move(pending.toPath(), target.toPath()); + Map directory = installDirectory( + target, pending, backup, true, originalHash, desiredHash); + File transaction = writeCoordinator(root, "INSTALL", "PUBLISHING", entry, true, + List.of(directory), List.of()); + Files.writeString(new File(target, "value.txt").toPath(), "changed", StandardCharsets.UTF_8); + + try { + DatapackIngestService.recoverTransactions(root, List.of(world)); + fail("Expected authored datapack mutation to block recovery"); + } catch (IOException expected) { + assertTrue(expected.getMessage().contains("content changed")); + } + + assertEquals("changed", Files.readString( + new File(target, "value.txt").toPath(), StandardCharsets.UTF_8)); + assertTrue(backup.exists()); + assertTrue(transaction.exists()); + } + @Test public void publishingInstallCrashRestoresManagedStagingWithEveryWorld() throws Exception { File root = temporaryFolder.newFolder("staging-install-crash-rollback-root"); diff --git a/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java index c7a1539fb..39d0ff76c 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.core.pack; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; +import org.junit.Assume; import org.junit.Test; import java.io.ByteArrayOutputStream; @@ -111,6 +112,42 @@ public class DefaultPackBootstrapProvisionerTest { } } + @Test + public void coldInstallPreservesSymbolicLinkPackWorkspace() throws Exception { + byte[] archive = packArchive("overworld", "bootstrap_biome"); + AtomicInteger requests = new AtomicInteger(); + HttpServer server = server(archive, requests); + Path root = Files.createTempDirectory("iris-bootstrap-linked-workspace"); + try { + Path dataDirectory = root.resolve("plugins/Iris"); + Path sharedPacks = root.resolve("shared-plugin-data/iris/packs"); + Files.createDirectories(dataDirectory); + Files.createDirectories(sharedPacks); + try { + Files.createSymbolicLink(dataDirectory.resolve("packs"), sharedPacks); + } catch (IOException | UnsupportedOperationException | SecurityException exception) { + Assume.assumeNoException(exception); + } + DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1)); + + DefaultPackBootstrapProvisioner.ProvisionResult installed = DefaultPackBootstrapProvisioner.provision( + dataDirectory, + ignored -> { + }, + options + ); + + assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status()); + assertEquals(1, requests.get()); + assertTrue(Files.isSymbolicLink(dataDirectory.resolve("packs"))); + assertTrue(Files.isRegularFile(sharedPacks.resolve("overworld/dimensions/overworld.json"))); + assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta"))); + } finally { + server.stop(0); + delete(root); + } + } + @Test public void corruptCacheRedownloadsAndRepairsArchive() throws Exception { byte[] archive = packArchive("overworld", "bootstrap_biome"); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDirectoryResolverTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDirectoryResolverTest.java index 183f0a1b3..7157d82f9 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackDirectoryResolverTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDirectoryResolverTest.java @@ -13,6 +13,7 @@ import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -78,6 +79,42 @@ public class PackDirectoryResolverTest { assertNull(PackDirectoryResolver.resolveExisting(packs, ".custom-stage")); } + @Test + public void listsPacksThroughSymbolicLinkWorkspace() throws Exception { + File sharedPacks = temporaryFolder.newFolder("shared-packs"); + File overworld = new File(sharedPacks, "overworld"); + Files.createDirectory(overworld.toPath()); + Path workspace = temporaryFolder.getRoot().toPath().resolve("packs"); + try { + Files.createSymbolicLink(workspace, sharedPacks.toPath()); + } catch (IOException | UnsupportedOperationException | SecurityException exception) { + Assume.assumeNoException(exception); + } + + assertEquals(List.of(workspace.resolve("overworld").toFile()), + PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(workspace.toFile())); + } + + @Test + public void missingWorkspaceListsNoPacks() throws Exception { + File missing = new File(temporaryFolder.getRoot(), "missing-packs"); + + assertEquals(List.of(), PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(missing)); + } + + @Test + public void rejectsDanglingSymbolicLinkWorkspace() throws Exception { + Path workspace = temporaryFolder.getRoot().toPath().resolve("dangling-packs"); + try { + Files.createSymbolicLink(workspace, temporaryFolder.getRoot().toPath().resolve("missing-target")); + } catch (IOException | UnsupportedOperationException | SecurityException exception) { + Assume.assumeNoException(exception); + } + + assertThrows(IOException.class, + () -> PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(workspace.toFile())); + } + @Test public void rejectsSymbolicLinksInsidePackTrees() throws Exception { File packs = temporaryFolder.newFolder("nested-link-root"); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorImportedStructurePolicyTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorImportedStructurePolicyTest.java index 23450a3b6..8d4e4de39 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorImportedStructurePolicyTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorImportedStructurePolicyTest.java @@ -110,19 +110,38 @@ public class PackValidatorImportedStructurePolicyTest { @Test public void explicitNullPolicyIsRejectedWhileOmissionUsesDefaults() { List missingErrors = new ArrayList<>(); - PackDimensionValidator.validateImportedStructurePolicy("overworld", new JSONObject(), missingErrors); + PackDimensionValidator.validateImportedStructurePolicy("overworld", new JSONObject(), + missingErrors, new ArrayList<>()); assertTrue(missingErrors.isEmpty()); List nullErrors = new ArrayList<>(); PackDimensionValidator.validateImportedStructurePolicy("overworld", - new JSONObject().put("importedStructures", JSONObject.NULL), nullErrors); + new JSONObject().put("importedStructures", JSONObject.NULL), nullErrors, new ArrayList<>()); assertEquals(List.of("Dimension 'overworld' importedStructures must be an object."), nullErrors); } + @Test + public void removedClearVegetationAdjustmentWarnsWithoutBlockingThePack() { + JSONObject policy = new JSONObject() + .put("adjustments", new JSONArray().put(new JSONObject() + .put("match", new JSONArray().put("minecraft:village")) + .put("clearVegetation", true))); + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + + PackDimensionValidator.validateImportedStructurePolicy("overworld", + new JSONObject().put("importedStructures", policy), errors, warnings); + + assertTrue(errors.toString(), errors.isEmpty()); + assertEquals(1, warnings.size()); + assertTrue(warnings.get(0), warnings.get(0) + .contains("adjustments[0].clearVegetation was removed and is ignored")); + } + private List validate(JSONObject policy) { List errors = new ArrayList<>(); PackDimensionValidator.validateImportedStructurePolicy("overworld", - new JSONObject().put("importedStructures", policy), errors); + new JSONObject().put("importedStructures", policy), errors, new ArrayList<>()); return errors; } } 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 index 2f6fe8ae5..bbfa2e4af 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureTerrainBackendTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureTerrainBackendTest.java @@ -87,7 +87,8 @@ public class PackValidatorStructureTerrainBackendTest { List errors = new ArrayList<>(); PackDimensionValidator.validateImportedStructurePolicy( - "overworld", new JSONObject().put("importedStructures", policy), errors); + "overworld", new JSONObject().put("importedStructures", policy), + errors, new ArrayList<>()); assertTrue(mode + ": " + errors, errors.isEmpty()); } 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 303432c0c..64e5f0037 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 @@ -13,6 +13,7 @@ import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.nio.ByteBuffer; import java.util.LinkedHashMap; import java.util.Map; @@ -25,6 +26,7 @@ import static org.junit.Assert.assertTrue; public class NativeStructureOwnershipRecordTest { private static final String FINGERPRINT = "12".repeat(32); + private static final int SCHEMA_OFFSET = 8; @Test public void binaryRoundTripPreservesMultipleVersionedOwnershipRecords() throws Exception { @@ -44,7 +46,7 @@ public class NativeStructureOwnershipRecordTest { } @Test - public void decisionSnapshotFreezesTerrainVegetationAndStiltSettings() { + public void decisionSnapshotFreezesTerrainAndStiltSettings() { IrisStructureTerrain terrain = new IrisStructureTerrain() .setMode(IrisStructureTerrainMode.FORCE_CARVE) .setHorizontalPadding(9) @@ -64,7 +66,6 @@ public class NativeStructureOwnershipRecordTest { 0, null, false, - true, stilt, terrain ); @@ -76,7 +77,6 @@ public class NativeStructureOwnershipRecordTest { IrisNativeStructureDecision restored = snapshot.restore(); assertTrue(restored.generate()); - assertTrue(restored.clearVegetation()); assertEquals(IrisStructureTerrainMode.FORCE_CARVE, restored.terrain().resolvedMode()); assertEquals(IrisStructureCarveShape.ERODED, restored.terrain().resolvedShape()); assertEquals(9, restored.terrain().getHorizontalPadding()); @@ -186,7 +186,6 @@ public class NativeStructureOwnershipRecordTest { new LinkedHashMap<>(); NativeStructureOwnershipRecord.DecisionSnapshot largeDecision = new NativeStructureOwnershipRecord.DecisionSnapshot( - false, "null", "{\"unused\":\"" + "x".repeat(65_000) + "\"}" ); @@ -302,6 +301,21 @@ public class NativeStructureOwnershipRecordTest { assertNull(record.restoredDecision().stilt()); } + @Test + public void bundlesWrittenUnderAnOlderSchemaRevisionFailClosed() throws Exception { + NativeStructureOwnershipRecord record = record("nova_structures:tavern_oak", 4, -7, 71L); + NativeStructureOwnershipBundle bundle = NativeStructureOwnershipBundle.empty().with(record); + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + bundle.write(new DataOutputStream(encoded)); + byte[] payload = encoded.toByteArray(); + ByteBuffer.wrap(payload).putInt(SCHEMA_OFFSET, NativeStructureOwnershipRecord.CURRENT_SCHEMA - 1); + + IOException failure = assertThrows(IOException.class, () -> NativeStructureOwnershipBundle.read( + new DataInputStream(new ByteArrayInputStream(payload)))); + + assertTrue(failure.getMessage(), failure.getMessage().contains("schema")); + } + @Test public void malformedButSizedBinaryRecordsFailAsIoErrors() throws Exception { ByteArrayOutputStream encoded = new ByteArrayOutputStream(); @@ -362,7 +376,6 @@ public class NativeStructureOwnershipRecordTest { 0, null, false, - true, null, new IrisStructureTerrain() )); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStoreTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStoreTest.java index 615cd5395..7e44b128e 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStoreTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructureOwnershipStoreTest.java @@ -31,7 +31,7 @@ public class NativeStructureOwnershipStoreTest { TestStorage storage = new TestStorage(); NativeStructureOwnershipStore.State state = new NativeStructureOwnershipStore.State(engine, storage); - NativeStructureOwnershipRecord record = record("test:origin_only", 4, -7, 91L, false); + NativeStructureOwnershipRecord record = record("test:origin_only", 4, -7, 91L); state.record(record); @@ -48,8 +48,10 @@ public class NativeStructureOwnershipStoreTest { public void originAuthorityIgnoresAStaleTargetReplicaWhenOnlyPolicyChanged() { Engine engine = engine(); TestStorage storage = new TestStorage(); - NativeStructureOwnershipRecord stale = record("test:replacement", 2, 3, 11L, false); - NativeStructureOwnershipRecord current = record("test:replacement", 2, 3, 11L, true); + NativeStructureOwnershipRecord stale = record("test:replacement", 2, 3, 11L, + new IrisStructureTerrain().setHorizontalPadding(2), 8); + NativeStructureOwnershipRecord current = record("test:replacement", 2, 3, 11L, + new IrisStructureTerrain().setHorizontalPadding(9), 8); long origin = NativeStructureOwnershipStore.pack(2, 3); long target = NativeStructureOwnershipStore.pack(3, 4); storage.write(origin, current); @@ -61,7 +63,7 @@ public class NativeStructureOwnershipStoreTest { 3, 4, current.structureKey(), 2, 3); assertEquals(current, resolved); - assertTrue(resolved.restoredDecision().clearVegetation()); + assertEquals(9, resolved.restoredDecision().terrain().getHorizontalPadding()); assertEquals(stale.contentFingerprint(), resolved.contentFingerprint()); assertEquals(stale, storage.find(target, stale)); } @@ -73,7 +75,7 @@ public class NativeStructureOwnershipStoreTest { NativeStructureOwnershipStore.State state = new NativeStructureOwnershipStore.State(engine, storage); NativeStructureOwnershipRecord record = record( - "test:narrow_authority", 8, -3, 19L, false, 1); + "test:narrow_authority", 8, -3, 19L, 1); state.record(record); assertNull(state.find( @@ -86,7 +88,7 @@ public class NativeStructureOwnershipStoreTest { public void staleTargetReplicaCannotReplaceAMissingOriginAuthority() { Engine engine = engine(); TestStorage storage = new TestStorage(); - NativeStructureOwnershipRecord stale = record("test:deleted", -2, 5, 17L, false); + NativeStructureOwnershipRecord stale = record("test:deleted", -2, 5, 17L); storage.write(NativeStructureOwnershipStore.pack(-1, 5), stale); NativeStructureOwnershipStore.State state = new NativeStructureOwnershipStore.State(engine, storage); @@ -105,7 +107,7 @@ public class NativeStructureOwnershipStoreTest { for (int chunkZ = -8; chunkZ <= 8; chunkZ++) { NativeStructureOwnershipRecord record = record( "test:dense_" + chunkX + "_" + chunkZ, - chunkX, chunkZ, records, false); + chunkX, chunkZ, records); state.record(record); assertEquals(record, state.find( 0, 0, record.structureKey(), chunkX, chunkZ)); @@ -126,7 +128,7 @@ public class NativeStructureOwnershipStoreTest { BlockingStorage storage = new BlockingStorage(); NativeStructureOwnershipStore.State state = new NativeStructureOwnershipStore.State(engine, storage); - NativeStructureOwnershipRecord record = record("test:flush_race", -4, 8, 42L, false); + NativeStructureOwnershipRecord record = record("test:flush_race", -4, 8, 42L); storage.blockedTarget = NativeStructureOwnershipStore.pack(-4, 8); ExecutorService callers = Executors.newFixedThreadPool(2); try { @@ -152,7 +154,7 @@ public class NativeStructureOwnershipStoreTest { BlockingStorage storage = new BlockingStorage(); NativeStructureOwnershipStore.State state = new NativeStructureOwnershipStore.State(engine, storage); - NativeStructureOwnershipRecord record = record("test:close_race", 6, -9, 73L, false); + NativeStructureOwnershipRecord record = record("test:close_race", 6, -9, 73L); storage.blockedTarget = NativeStructureOwnershipStore.pack(6, -9); ExecutorService callers = Executors.newFixedThreadPool(2); try { @@ -178,7 +180,7 @@ public class NativeStructureOwnershipStoreTest { NativeStructureOwnershipStore.State state = new NativeStructureOwnershipStore.State(engine, storage); NativeStructureOwnershipRecord record = record( - "test:closing_session", -7, 12, 74L, false); + "test:closing_session", -7, 12, 74L); state.record(record); @@ -207,8 +209,8 @@ public class NativeStructureOwnershipStoreTest { PostWriteBlockingStorage storage = new PostWriteBlockingStorage(); NativeStructureOwnershipStore.State state = new NativeStructureOwnershipStore.State(engine, storage); - NativeStructureOwnershipRecord first = record("test:same_origin", 3, -6, 1L, false); - NativeStructureOwnershipRecord second = record("test:same_origin", 3, -6, 2L, true); + NativeStructureOwnershipRecord first = record("test:same_origin", 3, -6, 1L); + NativeStructureOwnershipRecord second = record("test:same_origin", 3, -6, 2L); ExecutorService callers = Executors.newFixedThreadPool(2); try { Future firstWrite = callers.submit(() -> state.record(first)); @@ -238,7 +240,7 @@ public class NativeStructureOwnershipStoreTest { NativeStructureOwnershipStore.State state = new NativeStructureOwnershipStore.State(engine, storage); NativeStructureOwnershipRecord record = record( - "test:autosave_durable", -11, 14, 101L, true); + "test:autosave_durable", -11, 14, 101L); state.record(record); state.flush(); @@ -258,7 +260,7 @@ public class NativeStructureOwnershipStoreTest { NativeStructureOwnershipStore.State state = new NativeStructureOwnershipStore.State(engine, storage); NativeStructureOwnershipRecord record = record( - "test:flush_retry", 12, -15, 102L, false); + "test:flush_retry", 12, -15, 102L); state.record(record); assertThrows(IllegalStateException.class, state::flush); @@ -279,7 +281,7 @@ public class NativeStructureOwnershipStoreTest { NativeStructureOwnershipStore.State state = new NativeStructureOwnershipStore.State(engine, storage); NativeStructureOwnershipRecord record = record( - "test:clean_flush", 16, 17, 103L, false); + "test:clean_flush", 16, 17, 103L); state.flush(); state.record(record); @@ -294,7 +296,7 @@ public class NativeStructureOwnershipStoreTest { Engine engine = engine(); CrashableStorage storage = new CrashableStorage(); NativeStructureOwnershipRecord record = record( - "test:discard_durable", -18, 19, 104L, false); + "test:discard_durable", -18, 19, 104L); storage.write(NativeStructureOwnershipStore.pack(-18, 19), record); storage.flush(); NativeStructureOwnershipStore.State state = @@ -318,7 +320,7 @@ public class NativeStructureOwnershipStoreTest { NativeStructureOwnershipStore.State state = new NativeStructureOwnershipStore.State(engine, storage); NativeStructureOwnershipRecord record = record( - "test:close_durable", 20, -21, 105L, true); + "test:close_durable", 20, -21, 105L); state.record(record); state.close(); @@ -338,7 +340,7 @@ public class NativeStructureOwnershipStoreTest { NativeStructureOwnershipStore.State state = new NativeStructureOwnershipStore.State(engine, storage); NativeStructureOwnershipRecord record = record( - "test:close_retry", -22, 23, 106L, false); + "test:close_retry", -22, 23, 106L); state.record(record); assertThrows(IllegalStateException.class, state::close); @@ -362,14 +364,19 @@ public class NativeStructureOwnershipStoreTest { } private static NativeStructureOwnershipRecord record(String key, int originX, int originZ, - long placementIdentity, - boolean clearVegetation) { - return record(key, originX, originZ, placementIdentity, clearVegetation, 8); + long placementIdentity) { + return record(key, originX, originZ, placementIdentity, new IrisStructureTerrain(), 8); } private static NativeStructureOwnershipRecord record(String key, int originX, int originZ, long placementIdentity, - boolean clearVegetation, + int referenceRadius) { + return record(key, originX, originZ, placementIdentity, new IrisStructureTerrain(), referenceRadius); + } + + private static NativeStructureOwnershipRecord record(String key, int originX, int originZ, + long placementIdentity, + IrisStructureTerrain terrain, int referenceRadius) { return new NativeStructureOwnershipRecord( NativeStructureOwnershipRecord.CURRENT_SCHEMA, @@ -396,9 +403,8 @@ public class NativeStructureOwnershipStoreTest { 0, null, false, - clearVegetation, null, - new IrisStructureTerrain() + terrain )) ); } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java index 25423c09d..2bcb322c4 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java @@ -82,7 +82,6 @@ public class NativeStructurePlacementPlannerTest { IrisNativeStructureDecision decision = NativeStructurePlacementPlanner.decisionFor(plan); assertEquals(NativeStructureGenerationStatus.GENERATE_NATIVE, decision.status()); - assertEquals(false, decision.clearVegetation()); assertSame(terrain, decision.terrain()); } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweepTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweepTest.java new file mode 100644 index 000000000..d1e385ea1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweepTest.java @@ -0,0 +1,219 @@ +package art.arcane.iris.engine.mantle.components; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class CarveOrphanSweepTest { + private static final int WORLD_HEIGHT = 64; + private static final int WORLD_FLOOR_Y = 0; + private static final int WORLD_CEILING_Y = WORLD_HEIGHT - 1; + private static final int SURFACE_Y = 40; + private static final int SURFACE_BREAK_DEPTH = 18; + private static final int BAND_FLOOR_Y = SURFACE_Y - SURFACE_BREAK_DEPTH - 4; + + @Test + public void interiorClumpsOfOneTwoAndFiveCellsAreMarkedCarved() { + Fixture fixture = new Fixture(); + fixture.carveBox(1, 14, 25, 35, 1, 14); + fixture.uncarve(5, 30, 5); + fixture.uncarve(8, 30, 8); + fixture.uncarve(9, 30, 8); + fixture.uncarve(5, 33, 10); + fixture.uncarve(4, 33, 10); + fixture.uncarve(6, 33, 10); + fixture.uncarve(5, 33, 9); + fixture.uncarve(5, 33, 11); + + int marked = fixture.sweep(); + + assertEquals(8, marked); + assertTrue(fixture.wasMarked(5, 30, 5)); + assertTrue(fixture.wasMarked(8, 30, 8)); + assertTrue(fixture.wasMarked(9, 30, 8)); + assertTrue(fixture.wasMarked(5, 33, 10)); + assertTrue(fixture.wasMarked(4, 33, 10)); + assertTrue(fixture.wasMarked(6, 33, 10)); + assertTrue(fixture.wasMarked(5, 33, 9)); + assertTrue(fixture.wasMarked(5, 33, 11)); + } + + @Test + public void stalactiteHangingFromSurfaceCrustIsKept() { + Fixture fixture = new Fixture(); + fixture.carveBox(1, 14, 25, 35, 1, 14); + fixture.uncarve(7, 35, 7); + fixture.uncarve(7, 34, 7); + fixture.uncarve(7, 33, 7); + + int marked = fixture.sweep(); + + assertEquals(0, marked); + assertFalse(fixture.wasMarked(7, 35, 7)); + assertFalse(fixture.wasMarked(7, 34, 7)); + assertFalse(fixture.wasMarked(7, 33, 7)); + } + + @Test + public void componentTouchingSolidBelowBandFloorIsKept() { + Fixture fixture = new Fixture(); + fixture.carveBox(0, 15, BAND_FLOOR_Y, 35, 0, 15); + fixture.uncarve(7, BAND_FLOOR_Y, 7); + + int marked = fixture.sweep(); + + assertEquals(0, marked); + assertFalse(fixture.wasMarked(7, BAND_FLOOR_Y, 7)); + } + + @Test + public void componentContainingSeamCellIsKept() { + Fixture fixture = new Fixture(); + fixture.carveBox(0, 15, 25, 35, 0, 15); + fixture.uncarve(0, 30, 7); + fixture.uncarve(15, 31, 9); + fixture.uncarve(6, 32, 0); + fixture.uncarve(6, 33, 15); + + int marked = fixture.sweep(); + + assertEquals(0, marked); + assertFalse(fixture.wasMarked(0, 30, 7)); + assertFalse(fixture.wasMarked(15, 31, 9)); + assertFalse(fixture.wasMarked(6, 32, 0)); + assertFalse(fixture.wasMarked(6, 33, 15)); + } + + @Test + public void componentLargerThanSixteenCellsIsKept() { + Fixture fixture = new Fixture(); + fixture.carveBox(1, 14, 25, 35, 1, 14); + for (int localX = 3; localX <= 5; localX++) { + for (int localZ = 3; localZ <= 5; localZ++) { + fixture.uncarve(localX, 27, localZ); + fixture.uncarve(localX, 28, localZ); + } + } + + int marked = fixture.sweep(); + + assertEquals(0, marked); + assertFalse(fixture.wasMarked(4, 27, 4)); + assertFalse(fixture.wasMarked(4, 28, 4)); + } + + @Test + public void componentOfExactlySixteenCellsIsMarkedCarved() { + Fixture fixture = new Fixture(); + fixture.carveBox(1, 14, 25, 35, 1, 14); + for (int localX = 3; localX <= 6; localX++) { + for (int localZ = 3; localZ <= 6; localZ++) { + fixture.uncarve(localX, 30, localZ); + } + } + + int marked = fixture.sweep(); + + assertEquals(16, marked); + assertTrue(fixture.wasMarked(3, 30, 3)); + assertTrue(fixture.wasMarked(6, 30, 6)); + } + + @Test + public void sweepIsDeterministicAndIdempotent() { + Fixture first = new Fixture(); + first.carveBox(1, 14, 25, 35, 1, 14); + first.uncarve(5, 30, 5); + first.uncarve(8, 30, 8); + first.uncarve(9, 30, 8); + + Fixture second = new Fixture(); + second.carveBox(1, 14, 25, 35, 1, 14); + second.uncarve(5, 30, 5); + second.uncarve(8, 30, 8); + second.uncarve(9, 30, 8); + + int firstMarked = first.sweep(); + int secondMarked = second.sweep(); + + assertEquals(firstMarked, secondMarked); + assertEquals(first.marks(), second.marks()); + + first.clearMarks(); + int rerun = first.sweep(); + + assertEquals(0, rerun); + assertTrue(first.marks().isEmpty()); + } + + @Test + public void chunkWithoutCarveMarkersInBandDoesNothing() { + Fixture fixture = new Fixture(); + + int marked = fixture.sweep(); + + assertEquals(0, marked); + assertTrue(fixture.marks().isEmpty()); + } + + private static final class Fixture implements CarveOrphanSweep.CarveAccess { + private final boolean[] carved = new boolean[16 * WORLD_HEIGHT * 16]; + private final int[] surfaceHeights = new int[256]; + private final List marks = new ArrayList<>(); + + private Fixture() { + Arrays.fill(surfaceHeights, SURFACE_Y); + } + + private int sweep() { + return CarveOrphanSweep.sweep(surfaceHeights, SURFACE_BREAK_DEPTH, WORLD_FLOOR_Y, WORLD_CEILING_Y, this); + } + + private void carveBox(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) { + for (int localX = minX; localX <= maxX; localX++) { + for (int y = minY; y <= maxY; y++) { + for (int localZ = minZ; localZ <= maxZ; localZ++) { + carved[index(localX, y, localZ)] = true; + } + } + } + } + + private void uncarve(int localX, int y, int localZ) { + carved[index(localX, y, localZ)] = false; + } + + private boolean wasMarked(int localX, int y, int localZ) { + return marks.contains(index(localX, y, localZ)); + } + + private List marks() { + return marks; + } + + private void clearMarks() { + marks.clear(); + } + + private static int index(int localX, int y, int localZ) { + return (y * 256) + (localX * 16) + localZ; + } + + @Override + public boolean isCarved(int localX, int y, int localZ) { + return carved[index(localX, y, localZ)]; + } + + @Override + public void markCarved(int localX, int y, int localZ) { + carved[index(localX, y, localZ)] = true; + marks.add(index(localX, y, localZ)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java index 5063742f8..8ad0ede06 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java @@ -673,8 +673,6 @@ public class IrisCaveCarver3DNearParityTest { profile.setDensityThreshold(new IrisStyledRange(1D, 1D, new IrisGeneratorStyle(NoiseStyle.FLAT))); profile.setThresholdBias(0D); profile.setSampleStep(1); - profile.setMinCarveCells(0); - profile.setRecoveryThresholdBoost(0D); profile.setSurfaceClearance(5); profile.setAllowSurfaceBreak(true); profile.setSurfaceBreakNoiseThreshold(0.16D); 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 5064e5b95..faec2f926 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 @@ -159,9 +159,7 @@ public class IrisImportedStructureControlTest { @Test public void postprocessingDefaultsAreDisabled() { IrisImportedStructureControl control = new IrisImportedStructureControl(); - assertFalse(control.resolve("minecraft:woodland_mansion", false).clearVegetation()); assertNull(control.resolve("minecraft:village_plains", false).stilt()); - assertFalse(control.resolve(null, false).clearVegetation()); assertNull(control.resolve(null, false).stilt()); assertFalse(control.resolve("minecraft:mineshaft_mesa", true).preserveSourceY()); } @@ -253,24 +251,21 @@ public class IrisImportedStructureControlTest { IrisStructureStiltSettings stilt = new IrisStructureStiltSettings(); IrisVanillaStructureAdjustment adjustment = new IrisVanillaStructureAdjustment() .setMatch(keys("minecraft:village")) - .setClearVegetation(true) .setStilt(stilt); IrisImportedStructureControl control = new IrisImportedStructureControl() .setAdjustments(new KList().qadd(adjustment)); - assertTrue(control.resolve("minecraft:village_plains", false).clearVegetation()); assertSame(stilt, control.resolve("minecraft:village_taiga", false).stilt()); - assertFalse(control.resolve("minecraft:woodland_mansion", false).clearVegetation()); + assertNull(control.resolve("minecraft:woodland_mansion", false).stilt()); assertNull(control.resolve("minecraft:stronghold", true).stilt()); } @Test - public void multipleMatchesMergeVegetationAndUseLastConfiguredStilt() { + public void multipleMatchesUseTheLastConfiguredStilt() { IrisStructureStiltSettings broadStilt = new IrisStructureStiltSettings().setMaxDepth(32); IrisStructureStiltSettings specificStilt = new IrisStructureStiltSettings().setMaxDepth(96); IrisVanillaStructureAdjustment broad = new IrisVanillaStructureAdjustment() .setMatch(keys("minecraft:village")) - .setClearVegetation(true) .setStilt(broadStilt); IrisVanillaStructureAdjustment exactWithoutStilt = new IrisVanillaStructureAdjustment() .setMatch(keys("minecraft:village_plains")); @@ -284,7 +279,6 @@ public class IrisImportedStructureControlTest { IrisImportedStructureControl control = new IrisImportedStructureControl().setAdjustments(adjustments); IrisNativeStructureDecision plains = control.resolve("minecraft:village_plains", false); - assertTrue(plains.clearVegetation()); assertSame(specificStilt, plains.stilt()); assertEquals(96, plains.stilt().getMaxDepth()); assertSame(broadStilt, control.resolve("minecraft:village_desert", false).stilt()); diff --git a/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java b/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java new file mode 100644 index 000000000..423efea5d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/spi/IrisLoggingTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.spi; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class IrisLoggingTest { + @Before + public void resetBinding() { + IrisPlatforms.unbind(); + } + + @After + public void clearBinding() { + IrisPlatforms.unbind(); + } + + @Test + public void contextualReportPrintsFullStacktraceWithBoundPlatform() { + IrisPlatform platform = mock(IrisPlatform.class); + IrisPlatforms.bind(platform); + IllegalStateException failure = new IllegalStateException("outer", new IllegalArgumentException("inner")); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + PrintStream originalErr = System.err; + System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8)); + try { + IrisLogging.reportError("Runtime world creation failed.", failure); + } finally { + System.setErr(originalErr); + } + + verify(platform).log(LogLevel.ERROR, "Runtime world creation failed."); + verify(platform).reportError(failure); + String text = output.toString(StandardCharsets.UTF_8); + assertTrue(text.contains("IllegalStateException")); + assertTrue(text.contains("IllegalArgumentException")); + assertTrue(text.contains("inner")); + } +} diff --git a/core/src/test/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandlerTest.java b/core/src/test/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandlerTest.java index c75a4b3e3..11a37b2fe 100644 --- a/core/src/test/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandlerTest.java +++ b/core/src/test/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandlerTest.java @@ -250,7 +250,7 @@ public class StructureHandlerTest { } private static IrisNativeStructureDecision decision(NativeStructureGenerationStatus status) { - return new IrisNativeStructureDecision(status, 0, null, false, false, null, null); + return new IrisNativeStructureDecision(status, 0, null, false, null, null); } private static IrisStructurePlacement nativePlacement(String key, StructureDistribution distribution, diff --git a/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java b/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java index d218603b8..0df764f65 100644 --- a/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java +++ b/spi/src/main/java/art/arcane/iris/spi/IrisLogging.java @@ -98,6 +98,7 @@ public final class IrisLogging { } reportError(cause); + cause.printStackTrace(System.err); } /**