This commit is contained in:
Brian Neumann-Fopiano committed 2026-08-05 01:16:30 -06:00
1 parent aaccbacf32
commit cd217c05f9
174 files changed
+28830 -2645

No files matched your search

@@ -4,7 +4,7 @@ import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionException;
import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.GenerationSessionLease;
import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.IrisStructureLocator;
import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner; import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
import art.arcane.iris.engine.framework.NativeStructureStartPlan; import art.arcane.iris.engine.framework.NativeStructureStartPlan;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimension;
@@ -14,11 +14,15 @@ import art.arcane.iris.nativegen.NativeStructureGenerationException;
import art.arcane.iris.nativegen.NativeStructureStartInjector; import art.arcane.iris.nativegen.NativeStructureStartInjector;
import art.arcane.iris.nativegen.NativeStructureReferenceEnvelope; import art.arcane.iris.nativegen.NativeStructureReferenceEnvelope;
import art.arcane.iris.nativegen.NativeStructureLocateResults; import art.arcane.iris.nativegen.NativeStructureLocateResults;
import art.arcane.iris.nativegen.NativeStructureLocatePersistence;
import art.arcane.iris.nativegen.NativeStructureOwnershipRecovery;
import art.arcane.iris.nativegen.NativeStructurePostProcessor; import art.arcane.iris.nativegen.NativeStructurePostProcessor;
import art.arcane.iris.nativegen.NativeStructureReferenceRepair;
import art.arcane.iris.nativegen.NativeStructureSurfaceFitter; import art.arcane.iris.nativegen.NativeStructureSurfaceFitter;
import art.arcane.iris.nativegen.NativeStructureTerrainIntegrator; import art.arcane.iris.nativegen.NativeStructureTerrainIntegrator;
import art.arcane.iris.nativegen.NativeStructureVegetationClearer; import art.arcane.iris.nativegen.NativeStructureVegetationClearer;
import art.arcane.iris.nativegen.NativeStructureVerticalPlacer; import art.arcane.iris.nativegen.NativeStructureVerticalPlacer;
import art.arcane.iris.nativegen.NativeStructureVanillaLocator;
import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps; import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps;
import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.iris.util.common.data.IrisCustomData;
@@ -87,6 +91,7 @@ import java.lang.reflect.Field;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -131,55 +136,99 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
public @Nullable Pair<BlockPos, Holder<Structure>> findNearestMapStructure(ServerLevel level, HolderSet<Structure> holders, BlockPos pos, int radius, boolean findUnexplored) { public @Nullable Pair<BlockPos, Holder<Structure>> findNearestMapStructure(ServerLevel level, HolderSet<Structure> holders, BlockPos pos, int radius, boolean findUnexplored) {
try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_structure_locate"); try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_structure_locate");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
Pair<BlockPos, Holder<Structure>> irisPlaced = findNearestIrisStructure(
level, holders, pos, Math.max(1, radius), findUnexplored);
HolderSet<Structure> reachable = filterReachableStructures(level, holders); HolderSet<Structure> reachable = filterReachableStructures(level, holders);
Pair<BlockPos, Holder<Structure>> nativeLocated = reachable == null || reachable.size() == 0 NativeStructureVanillaLocator.Candidate nativeCandidate =
? null reachable == null || reachable.size() == 0 ? null
: delegate.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); : NativeStructureVanillaLocator.predict(
return NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated); level, reachable, pos, radius, findUnexplored);
return findNearestIrisStructure(
level, holders, pos, Math.max(0, radius),
findUnexplored, nativeCandidate);
} }
} }
private Pair<BlockPos, Holder<Structure>> findNearestIrisStructure(ServerLevel level, private Pair<BlockPos, Holder<Structure>> findNearestIrisStructure(ServerLevel level,
HolderSet<Structure> holders, HolderSet<Structure> holders,
BlockPos pos, int radius, BlockPos pos, int radius,
boolean findUnexplored) { boolean findUnexplored,
if (findUnexplored) { NativeStructureVanillaLocator.Candidate nativeCandidate) {
return null; Pair<BlockPos, Holder<Structure>> nativeLocated =
} nativeCandidate == null ? null : nativeCandidate.result();
Runnable nativeReference = () -> {
if (nativeCandidate != null) {
nativeCandidate.reference(level.structureManager());
}
};
Registry<Structure> registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); Registry<Structure> registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
BlockPos best = null; List<IrisNativeLocateSearch> searches = new ArrayList<>(holders.size());
Holder<Structure> bestHolder = null; NativeStructureLocatePersistence.ProbeBudget budget = NativeStructureLocatePersistence.probeBudget();
long bestDist = Long.MAX_VALUE;
for (Holder<Structure> holder : holders) { for (Holder<Structure> holder : holders) {
Object id = registry.getKey(holder.value()); Object id = registry.getKey(holder.value());
if (id == null) { if (id == null) {
throw new IllegalStateException("Native structure locate received an unregistered structure holder"); throw new IllegalStateException("Native structure locate received an unregistered structure holder");
} }
String structureId = id.toString(); String structureId = id.toString();
if (!IrisStructureLocator.isPlaced(engine, structureId)) { if (!IrisStructureLocator.hasNativePlacement(engine, structureId)) {
continue; continue;
} }
IrisStructureLocator.LocateResult result = IrisStructureLocator.locate( NativeStructureLocatePersistence.Probe probe = NativeStructureLocatePersistence.probe(
engine, structureId, pos.getX(), pos.getZ(), radius); level, holder.value(), findUnexplored, budget);
if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { searches.add(new IrisNativeLocateSearch(
throw new IllegalStateException("Iris structure locate reached its safety limit for " holder, structureId, NativeStructureLocatePersistence.search(
+ structureId + " within " + radius + " chunks"); engine, structureId, pos.getX(), pos.getZ(), radius, probe)));
}
if (!result.found()) {
continue;
}
long dx = (long) result.originX() - pos.getX();
long dz = (long) result.originZ() - pos.getZ();
long d = dx * dx + dz * dz;
if (d < bestDist) {
bestDist = d;
best = new BlockPos(result.originX(), result.baseY(), result.originZ());
bestHolder = holder;
}
} }
return best == null ? null : Pair.of(best, bestHolder); searches.sort(Comparator.comparing(IrisNativeLocateSearch::structureId));
for (int attempt = 0; attempt < NativeStructureLocatePersistence.MAX_SELECTED_CANDIDATE_RETRIES; attempt++) {
IrisNativeLocateSearch bestSearch = null;
IrisStructureLocator.LocateResult bestResult = null;
long bestDistance = Long.MAX_VALUE;
for (IrisNativeLocateSearch search : searches) {
IrisStructureLocator.LocateResult result = search.search().predict();
if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) {
throw new IllegalStateException("Iris structure locate reached its safety limit for "
+ search.structureId() + " within " + radius + " placement rings");
}
if (!result.found()) {
continue;
}
long dx = (long) result.originX() - pos.getX();
long dz = (long) result.originZ() - pos.getZ();
long distance = dx * dx + dz * dz;
if (distance < bestDistance) {
bestDistance = distance;
bestSearch = search;
bestResult = result;
}
}
if (bestSearch == null) {
return NativeStructureLocateResults.selectAndReference(
pos, null, () -> { }, nativeLocated, nativeReference);
}
Pair<BlockPos, Holder<Structure>> predicted = Pair.of(
new BlockPos(bestResult.originX(), bestResult.baseY(), bestResult.originZ()),
bestSearch.holder());
if (NativeStructureLocateResults.nearest(pos, predicted, nativeLocated) != predicted) {
return NativeStructureLocateResults.selectAndReference(
pos, predicted, () -> { }, nativeLocated, nativeReference);
}
NativeStructureLocatePersistence.VerifiedStart verified =
bestSearch.search().verify(bestResult);
if (verified == null) {
bestSearch.search().reject(bestResult);
continue;
}
BlockPos located = new BlockPos(
bestResult.originX(), verified.ownership().locatorY(),
bestResult.originZ());
Pair<BlockPos, Holder<Structure>> irisLocated = Pair.of(located, bestSearch.holder());
IrisNativeLocateSearch selectedSearch = bestSearch;
NativeStructureLocatePersistence.VerifiedStart selectedStart = verified;
return NativeStructureLocateResults.selectAndReference(
pos, irisLocated, () -> selectedSearch.search().reference(selectedStart),
nativeLocated, nativeReference);
}
throw new IllegalStateException("Iris structure locate rejected too many selected candidates within "
+ radius + " placement rings");
} }
private HolderSet<Structure> filterReachableStructures(ServerLevel level, HolderSet<Structure> holders) { private HolderSet<Structure> filterReachableStructures(ServerLevel level, HolderSet<Structure> holders) {
@@ -323,9 +372,10 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
decision.preserveSourceY(), decision.preserveSourceY(),
decision.yBand(), decision.yBand(),
(x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()); (x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight());
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( StructureStart wrapped = NativeStructureReferenceEnvelope.wrapForPublication(
start, structure, start.getReferences(), templateManager, start, structure, start.getReferences(),
NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain())); NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain()),
structureId);
access.setStartForStructure(structure, wrapped); access.setStartForStructure(structure, wrapped);
} catch (Throwable error) { } catch (Throwable error) {
throw NativeStructureGenerationException.failure( throw NativeStructureGenerationException.failure(
@@ -341,7 +391,11 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
@Override @Override
public void createReferences(WorldGenLevel generatoraccessseed, StructureManager structuremanager, ChunkAccess ichunkaccess) { public void createReferences(WorldGenLevel generatoraccessseed, StructureManager structuremanager, ChunkAccess ichunkaccess) {
delegate.createReferences(generatoraccessseed, structuremanager, ichunkaccess); try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_references");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
NativeStructureReferenceRepair.createReferences(
engine, generatoraccessseed, structuremanager, ichunkaccess);
}
} }
@Override @Override
@@ -474,7 +528,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
int steps = GenerationStep.Decoration.values().length; int steps = GenerationStep.Decoration.values().length;
List<NativePlacementGroup> placementGroups = new ArrayList<>(); List<NativePlacementGroup> placementGroups = new ArrayList<>();
List<StructureStart> heightmapStarts = new ArrayList<>(); List<StructureStart> heightmapStarts = new ArrayList<>();
List<StructureStart> nativeStarts = new ArrayList<>();
List<NativeStructureVegetationClearer.VegetationTarget> vegetationTargets = new ArrayList<>(); List<NativeStructureVegetationClearer.VegetationTarget> vegetationTargets = new ArrayList<>();
List<NativeStructureTerrainIntegrator.TerrainTarget> terrainTargets = new ArrayList<>(); List<NativeStructureTerrainIntegrator.TerrainTarget> terrainTargets = new ArrayList<>();
for (int step = 0; step < steps; step++) { for (int step = 0; step < steps; step++) {
@@ -492,10 +545,11 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
List<StructureStart> starts = structureManager.startsForStructure(sectionPos, structure); List<StructureStart> starts = structureManager.startsForStructure(sectionPos, structure);
List<NativePlacement> resolvedPlacements = new ArrayList<>(starts.size()); List<NativePlacement> resolvedPlacements = new ArrayList<>(starts.size());
for (StructureStart start : starts) { for (StructureStart start : starts) {
NativeStructureStartPlan plan = NativeStructurePlacementPlanner.matchingPlan( NativeStructureOwnershipRecord ownership =
engine, structureId, start.getChunkPos().x(), start.getChunkPos().z()); NativeStructureOwnershipRecovery.resolve(
IrisNativeStructureDecision decision = plan == null engine, world.getLevel(), structureId, structure, start);
? sourceDecision : NativeStructurePlacementPlanner.decisionFor(plan); IrisNativeStructureDecision decision =
ownership == null ? sourceDecision : ownership.restoredDecision();
if (!decision.generate()) { if (!decision.generate()) {
continue; continue;
} }
@@ -505,9 +559,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
structureId, start, structureId, start,
NativeStructureTerrainIntegrator.resolveNativeTerrain( NativeStructureTerrainIntegrator.resolveNativeTerrain(
start, decision.terrain()))); start, decision.terrain())));
if (plan == null || !plan.placement().isUnderground()) {
nativeStarts.add(start);
}
boolean clearEntireFootprint = NativeStructureVegetationClearer boolean clearEntireFootprint = NativeStructureVegetationClearer
.shouldClearEntireVegetationFootprint( .shouldClearEntireVegetationFootprint(
structure.step(), decision.clearVegetation()); structure.step(), decision.clearVegetation());
@@ -533,15 +584,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
"heightmap priming", nativeStructureBatchContext(placementGroups), "heightmap priming", nativeStructureBatchContext(placementGroups),
chunkPos.x(), chunkPos.z(), error); chunkPos.x(), chunkPos.z(), error);
} }
try {
NativeStructureSurfaceFitter.prepareSurfaceStructures(
world, area, nativeStarts,
(x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight());
} catch (Throwable error) {
throw NativeStructureGenerationException.failure(
"terrain integration", nativeStructureBatchContext(placementGroups),
chunkPos.x(), chunkPos.z(), error);
}
try { try {
NativeStructureVegetationClearer.clearIntersectingVegetation( NativeStructureVegetationClearer.clearIntersectingVegetation(
world, chunk, area, vegetationTargets); world, chunk, area, vegetationTargets);
@@ -550,6 +592,15 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
"vegetation cleanup", nativeStructureBatchContext(placementGroups), "vegetation cleanup", nativeStructureBatchContext(placementGroups),
chunkPos.x(), chunkPos.z(), error); chunkPos.x(), chunkPos.z(), error);
} }
try {
NativeStructureSurfaceFitter.prepareSurfaceStructures(
world, area, terrainTargets,
(x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight());
} catch (Throwable error) {
throw NativeStructureGenerationException.failure(
"terrain integration", nativeStructureBatchContext(placementGroups),
chunkPos.x(), chunkPos.z(), error);
}
try { try {
NativeStructurePostProcessor.prepareTerrain( NativeStructurePostProcessor.prepareTerrain(
world, area, terrainTargets, this::resolvePaletteBlock); world, area, terrainTargets, this::resolvePaletteBlock);
@@ -641,8 +692,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
ChunkPos cp = chunk.getPos(); ChunkPos cp = chunk.getPos();
int i = cp.getMinBlockX(); int i = cp.getMinBlockX();
int j = cp.getMinBlockZ(); int j = cp.getMinBlockZ();
int minY = chunk.getMinY(); int minY = chunk.getMinY() + 1;
int maxY = minY + chunk.getHeight() - 1; int maxY = chunk.getMinY() + chunk.getHeight() - 1;
return new BoundingBox(i, minY, j, i + 15, maxY, j + 15); return new BoundingBox(i, minY, j, i + 15, maxY, j + 15);
} }
@@ -827,4 +878,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
private record NativeLocateCandidate(Holder<Structure> holder, String key) { private record NativeLocateCandidate(Holder<Structure> holder, String key) {
} }
private record IrisNativeLocateSearch(Holder<Structure> holder, String structureId,
NativeStructureLocatePersistence.Search search) {
}
} }
@@ -15,7 +15,9 @@ import art.arcane.iris.engine.data.chunk.TerrainChunk;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.object.IrisDimensionRuntimeContract;
import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.nativegen.NativeStructureFactory;
import art.arcane.iris.nativegen.NativeStructureGenerationException; import art.arcane.iris.nativegen.NativeStructureGenerationException;
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
import art.arcane.iris.util.project.agent.Agent; import art.arcane.iris.util.project.agent.Agent;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.collection.KMap;
@@ -102,6 +104,7 @@ import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.levelgen.WorldgenRandom; import net.minecraft.world.level.levelgen.WorldgenRandom;
import net.minecraft.world.level.levelgen.XoroshiroRandomSource; import net.minecraft.world.level.levelgen.XoroshiroRandomSource;
import net.minecraft.world.level.levelgen.structure.StructureCheck; import net.minecraft.world.level.levelgen.structure.StructureCheck;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure; import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure;
import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature; import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature;
@@ -557,6 +560,62 @@ public class NMSBinding implements INMSBinding {
return keys; return keys;
} }
@Override
public JigsawSourceMetadata getJigsawSourceMetadata(String structureKey) {
try {
Identifier identifier = Identifier.tryParse(structureKey);
if (identifier == null) {
throw new IllegalArgumentException("Invalid registered structure key: " + structureKey);
}
Registry<Structure> structures = registry().lookupOrThrow(Registries.STRUCTURE);
Structure structure = structures.getValue(identifier);
if (structure == null) {
throw new IllegalArgumentException("Registered structure does not exist: " + structureKey);
}
if (!(structure instanceof JigsawStructure jigsaw)) {
throw new IllegalArgumentException("Registered structure is not a jigsaw: " + structureKey);
}
MinecraftServer server = ((CraftServer) Bukkit.getServer()).getHandle().getServer();
return NativeStructureFactory.sourceMetadata(
registry(), server.getStructureManager(), jigsaw);
} catch (RuntimeException error) {
throw new IllegalStateException("Iris failed to resolve live jigsaw metadata for registered structure '"
+ structureKey + "'", error);
}
}
@Override
public int getTemplatePoolHorizontalSpan(String templatePoolKey) {
try {
MinecraftServer server = ((CraftServer) Bukkit.getServer()).getHandle().getServer();
return NativeStructureFactory.templatePoolHorizontalSpan(
registry(), server.getStructureManager(), templatePoolKey);
} catch (RuntimeException error) {
throw new IllegalStateException("Iris failed to resolve the live horizontal span for registered "
+ "template pool '" + templatePoolKey + "'", error);
}
}
@Override
public int getJigsawStartPoolHorizontalSpan(String structureKey, String templatePoolKey) {
try {
Identifier identifier = Identifier.tryParse(structureKey);
if (identifier == null) {
throw new IllegalArgumentException("Invalid registered structure key: " + structureKey);
}
Structure structure = registry().lookupOrThrow(Registries.STRUCTURE).getValue(identifier);
if (!(structure instanceof JigsawStructure jigsaw)) {
throw new IllegalArgumentException("Registered structure is not a jigsaw: " + structureKey);
}
MinecraftServer server = ((CraftServer) Bukkit.getServer()).getHandle().getServer();
return NativeStructureFactory.jigsawStartPoolHorizontalSpan(
registry(), server.getStructureManager(), jigsaw, templatePoolKey);
} catch (RuntimeException error) {
throw new IllegalStateException("Iris failed to resolve the effective start-pool span for registered "
+ "jigsaw structure '" + structureKey + "' and pool '" + templatePoolKey + "'", error);
}
}
@Override @Override
public KList<String> getStructureSetKeys() { public KList<String> getStructureSetKeys() {
KList<String> keys = new KList<>(); KList<String> keys = new KList<>();
@@ -700,7 +759,7 @@ public class NMSBinding implements INMSBinding {
return null; return null;
} }
net.minecraft.world.level.levelgen.structure.BoundingBox box = start.getBoundingBox(); BoundingBox box = start.getBoundingBox();
int spanX = box.maxX() - box.minX() + 1; int spanX = box.maxX() - box.minX() + 1;
int spanY = box.maxY() - box.minY() + 1; int spanY = box.maxY() - box.minY() + 1;
int spanZ = box.maxZ() - box.minZ() + 1; int spanZ = box.maxZ() - box.minZ() + 1;
@@ -717,7 +776,7 @@ public class NMSBinding implements INMSBinding {
for (int cz = minCZ; cz <= maxCZ; cz++) { for (int cz = minCZ; cz <= maxCZ; cz++) {
level.getChunk(cx, cz); level.getChunk(cx, cz);
net.minecraft.world.level.ChunkPos cp = new net.minecraft.world.level.ChunkPos(cx, cz); net.minecraft.world.level.ChunkPos cp = new net.minecraft.world.level.ChunkPos(cx, cz);
net.minecraft.world.level.levelgen.structure.BoundingBox chunkBox = new net.minecraft.world.level.levelgen.structure.BoundingBox( BoundingBox chunkBox = new BoundingBox(
cp.getMinBlockX(), box.minY(), cp.getMinBlockZ(), cp.getMinBlockX(), box.minY(), cp.getMinBlockZ(),
cp.getMaxBlockX(), box.maxY(), cp.getMaxBlockZ()); cp.getMaxBlockX(), box.maxY(), cp.getMaxBlockZ());
start.placeInChunk(level, structureManager, generator, random, chunkBox, cp); start.placeInChunk(level, structureManager, generator, random, chunkBox, cp);
@@ -49,9 +49,9 @@ public class IrisChunkGeneratorFailureContractTest {
assertTrue(placement.contains("because structure generation is disabled outside the pack")); assertTrue(placement.contains("because structure generation is disabled outside the pack"));
assertTrue(placement.contains("prepareSurfaceStructures")); assertTrue(placement.contains("prepareSurfaceStructures"));
assertTrue(placement.contains("clearIntersectingVegetation")); assertTrue(placement.contains("clearIntersectingVegetation"));
assertTrue(placement.indexOf("prepareSurfaceStructures")
< placement.indexOf("clearIntersectingVegetation"));
assertTrue(placement.indexOf("clearIntersectingVegetation") assertTrue(placement.indexOf("clearIntersectingVegetation")
< placement.indexOf("prepareSurfaceStructures"));
assertTrue(placement.indexOf("prepareSurfaceStructures")
< placement.indexOf("for (NativePlacementGroup group")); < placement.indexOf("for (NativePlacementGroup group"));
assertFalse(placement.contains("IrisLogging.reportError")); assertFalse(placement.contains("IrisLogging.reportError"));
} }
@@ -66,6 +66,20 @@ public class IrisChunkGeneratorFailureContractTest {
assertTrue(source.contains("catch (GenerationSessionException e)")); assertTrue(source.contains("catch (GenerationSessionException e)"));
} }
@Test
public void structureReferenceRepairIsGenerationLeased() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
int referencesStart = source.indexOf("public void createReferences");
int referencesEnd = source.indexOf("public CompletableFuture<ChunkAccess> createBiomes", referencesStart);
String references = source.substring(referencesStart, referencesEnd);
assertTrue(references.contains("requireGenerationLease(\"bukkit_nms_create_references\")"));
assertTrue(references.contains("IrisContext.open(engine, lease.sessionId(), null)"));
assertTrue(references.contains("NativeStructureReferenceRepair.createReferences("));
assertFalse(references.contains("delegate.createReferences("));
assertFalse(references.contains("catch ("));
}
@Test @Test
public void terrainWritesPrimeTheWorldgenHeightmapsForEveryChunk() throws IOException { public void terrainWritesPrimeTheWorldgenHeightmapsForEveryChunk() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
@@ -89,6 +103,7 @@ public class IrisChunkGeneratorFailureContractTest {
assertTrue(placement.indexOf("WorldgenTerrainHeightmaps.primeStructurePlacement(") assertTrue(placement.indexOf("WorldgenTerrainHeightmaps.primeStructurePlacement(")
< placement.indexOf("prepareSurfaceStructures")); < placement.indexOf("prepareSurfaceStructures"));
assertTrue(source.contains("engine.acquireGenerationLease(\"bukkit_nms_worldgen_heightmaps\")")); assertTrue(source.contains("engine.acquireGenerationLease(\"bukkit_nms_worldgen_heightmaps\")"));
assertTrue(source.contains("int minY = chunk.getMinY() + 1;"));
} }
@Test @Test
@@ -112,6 +127,38 @@ public class IrisChunkGeneratorFailureContractTest {
assertTrue(source.contains("import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps;")); assertTrue(source.contains("import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps;"));
} }
@Test
public void onlySidecarOwnedInjectedStartsUsePersistedPlacementPolicy() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
int placementStart = source.indexOf("private void placeVanillaStructures");
int placementEnd = source.indexOf("private static String nativeStructureBatchContext", placementStart);
String placement = source.substring(placementStart, placementEnd);
int ownershipResolution = placement.indexOf(
"NativeStructureOwnershipRecovery.resolve(");
int persistedDecision = placement.indexOf("ownership.restoredDecision()", ownershipResolution);
int adjustmentStart = source.indexOf("private void adjustGeneratedStructures");
int adjustmentEnd = source.indexOf("public ChunkGeneratorStructureState createState", adjustmentStart);
String adjustment = source.substring(adjustmentStart, adjustmentEnd);
Path nativegen = Path.of(System.getProperty("iris.nativeStructurePostProcessorSource")).getParent();
String factory = Files.readString(nativegen.resolve("NativeStructureFactory.java"));
String injector = Files.readString(nativegen.resolve("NativeStructureStartInjector.java"));
String recovery = Files.readString(nativegen.resolve("NativeStructureOwnershipRecovery.java"));
int ownershipRecord = injector.indexOf("NativeStructureOwnershipStore.record(");
int startPublication = injector.indexOf(
"context.structureManager().setStartForStructure(", ownershipRecord);
assertTrue(ownershipResolution >= 0);
assertTrue(persistedDecision > ownershipResolution);
assertTrue(recovery.contains("NativeStructureOwnershipStore.findPersisted("));
assertTrue(recovery.contains("NativeStructureOwnershipStore.record("));
assertTrue(ownershipRecord >= 0);
assertTrue(startPublication > ownershipRecord);
assertTrue(factory.contains("NativeStructureReferenceEnvelope.wrapForPublication("));
assertTrue(adjustment.contains("NativeStructureReferenceEnvelope.wrapForPublication("));
assertFalse(source.contains("wrapManaged("));
assertFalse(source.contains("isIrisManagedStart("));
}
private static int occurrences(String source, String needle) { private static int occurrences(String source, String needle) {
int count = 0; int count = 0;
int index = source.indexOf(needle); int index = source.indexOf(needle);
@@ -10,7 +10,9 @@ import org.junit.Test;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
@@ -27,17 +29,19 @@ public class IrisChunkGeneratorMonumentLocateContractTest {
assertTrue(filterStart > irisHelperStart); assertTrue(filterStart > irisHelperStart);
String outerMethod = source.substring(findStart, irisHelperStart); String outerMethod = source.substring(findStart, irisHelperStart);
String irisHelper = source.substring(irisHelperStart, filterStart); String irisHelper = source.substring(irisHelperStart, filterStart);
int unexploredGuard = irisHelper.indexOf("if (findUnexplored)");
int registryLookup = irisHelper.indexOf("level.registryAccess().lookupOrThrow(Registries.STRUCTURE)"); int registryLookup = irisHelper.indexOf("level.registryAccess().lookupOrThrow(Registries.STRUCTURE)");
int placedCheck = irisHelper.indexOf( int placedCheck = irisHelper.indexOf(
"if (!IrisStructureLocator.isPlaced(engine, structureId))"); "if (!IrisStructureLocator.hasNativePlacement(engine, structureId))");
int irisLocate = irisHelper.indexOf("IrisStructureLocator.locate(", placedCheck); int irisLocate = irisHelper.indexOf("NativeStructureLocatePersistence.search(", placedCheck);
int searchLimit = irisHelper.indexOf("LocateStatus.SEARCH_LIMIT_REACHED", irisLocate); int searchLimit = irisHelper.indexOf("LocateStatus.SEARCH_LIMIT_REACHED", irisLocate);
int limitSkip = irisHelper.indexOf("continue;", searchLimit);
int nativeFilter = outerMethod.indexOf("filterReachableStructures(level, holders)"); int nativeFilter = outerMethod.indexOf("filterReachableStructures(level, holders)");
int delegateLocate = outerMethod.indexOf("delegate.findNearestMapStructure(level, reachable"); int nativePrediction = outerMethod.indexOf("NativeStructureVanillaLocator.predict(", nativeFilter);
int nearestSelection = outerMethod.indexOf( int irisResolution = outerMethod.indexOf("return findNearestIrisStructure(", nativePrediction);
"NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated)"); int nearestSelection = irisHelper.indexOf(
"NativeStructureLocateResults.nearest(pos, predicted, nativeLocated)");
int selectedVerification = irisHelper.indexOf("bestSearch.search().verify(bestResult)", nearestSelection);
int selectedReference = irisHelper.indexOf(
"selectedSearch.search().reference(selectedStart)", selectedVerification);
int reachabilityStart = source.indexOf("private Set<String> reachableStructureKeys", filterStart); int reachabilityStart = source.indexOf("private Set<String> reachableStructureKeys", filterStart);
assertTrue(reachabilityStart > filterStart); assertTrue(reachabilityStart > filterStart);
String filterMethod = source.substring(filterStart, reachabilityStart); String filterMethod = source.substring(filterStart, reachabilityStart);
@@ -46,22 +50,28 @@ public class IrisChunkGeneratorMonumentLocateContractTest {
int emptyNativePartition = filterMethod.indexOf("if (candidates.isEmpty())", filterContinue); int emptyNativePartition = filterMethod.indexOf("if (candidates.isEmpty())", filterContinue);
int reachabilityLookup = filterMethod.indexOf("reachableStructureKeys(level)", emptyNativePartition); int reachabilityLookup = filterMethod.indexOf("reachableStructureKeys(level)", emptyNativePartition);
assertTrue(unexploredGuard >= 0); assertTrue(registryLookup >= 0);
assertTrue(registryLookup > unexploredGuard);
assertTrue(placedCheck > registryLookup); assertTrue(placedCheck > registryLookup);
assertTrue(irisLocate > placedCheck); assertTrue(irisLocate > placedCheck);
assertTrue(searchLimit > irisLocate); assertTrue(searchLimit > irisLocate);
assertTrue(limitSkip > searchLimit);
assertTrue(nativeFilter >= 0); assertTrue(nativeFilter >= 0);
assertTrue(delegateLocate > nativeFilter); assertTrue(nativePrediction > nativeFilter);
assertTrue(nearestSelection > delegateLocate); assertTrue(irisResolution > nativePrediction);
assertTrue(nearestSelection > irisLocate);
assertTrue(selectedVerification > nearestSelection);
assertTrue(selectedReference > selectedVerification);
assertTrue(policyFilter >= 0); assertTrue(policyFilter >= 0);
assertTrue(filterContinue > policyFilter); assertTrue(filterContinue > policyFilter);
assertTrue(emptyNativePartition > filterContinue); assertTrue(emptyNativePartition > filterContinue);
assertTrue(reachabilityLookup > emptyNativePartition); assertTrue(reachabilityLookup > emptyNativePartition);
assertFalse(filterMethod.contains("NativeStructureLocateCapability")); assertFalse(filterMethod.contains("NativeStructureLocateCapability"));
assertFalse(irisHelper.contains("NativeStructureLocateCapability.isPaperUnavailable(structureId)")); assertFalse(irisHelper.contains("NativeStructureLocateCapability.isPaperUnavailable(structureId)"));
assertTrue(irisHelper.contains("new BlockPos(result.originX(), result.baseY(), result.originZ())")); assertTrue(irisHelper.contains("NativeStructureLocatePersistence.probe("));
assertTrue(irisHelper.contains("NativeStructureLocateResults.selectAndReference("));
assertTrue(irisHelper.contains("selectedSearch.search().reference(selectedStart)"));
assertTrue(irisHelper.contains("findUnexplored"));
assertTrue(irisHelper.contains("verified.ownership().locatorY()"));
assertFalse(outerMethod.contains("delegate.findNearestMapStructure("));
} }
@Test @Test
@@ -77,6 +87,50 @@ public class IrisChunkGeneratorMonumentLocateContractTest {
assertSame(nativeTie, NativeStructureLocateResults.nearest(origin, irisNear, nativeTie)); assertSame(nativeTie, NativeStructureLocateResults.nearest(origin, irisNear, nativeTie));
} }
@Test
public void mixedUnexploredLocateReferencesOnlyTheSelectedProvider() {
BlockPos origin = BlockPos.ZERO;
Pair<BlockPos, Holder<Structure>> irisNear = Pair.of(new BlockPos(4, 70, 0), null);
Pair<BlockPos, Holder<Structure>> nativeFar = Pair.of(new BlockPos(8, 70, 0), null);
AtomicInteger irisReferences = new AtomicInteger();
AtomicInteger nativeReferences = new AtomicInteger();
Pair<BlockPos, Holder<Structure>> irisSelected =
NativeStructureLocateResults.selectAndReference(
origin,
irisNear, () -> irisReferences.incrementAndGet(),
nativeFar, () -> nativeReferences.incrementAndGet());
assertSame(irisNear, irisSelected);
assertEquals(1, irisReferences.get());
assertEquals(0, nativeReferences.get());
Pair<BlockPos, Holder<Structure>> nativeNear = Pair.of(new BlockPos(2, 70, 0), null);
Pair<BlockPos, Holder<Structure>> nativeSelected =
NativeStructureLocateResults.selectAndReference(
origin,
irisNear, () -> irisReferences.incrementAndGet(),
nativeNear, () -> nativeReferences.incrementAndGet());
assertSame(nativeNear, nativeSelected);
assertEquals(1, irisReferences.get());
assertEquals(1, nativeReferences.get());
}
@Test
public void nativePredictionIsReadOnlyUntilTheWinnerIsCommitted() throws IOException {
Path nativegen = Path.of(System.getProperty("iris.nativeStructurePostProcessorSource")).getParent();
String source = Files.readString(nativegen.resolve("NativeStructureVanillaLocator.java"));
int predictionStart = source.indexOf("private static Candidate predictAt(");
int candidateStart = source.indexOf("public static final class Candidate", predictionStart);
String prediction = source.substring(predictionStart, candidateStart);
String candidate = source.substring(candidateStart);
assertFalse(prediction.contains("addReference("));
assertTrue(candidate.contains("structureManager.addReference(referenceStart)"));
assertTrue(candidate.contains("committed.compareAndSet(false, true)"));
}
@Test @Test
public void stiltSupportUsesPlacedSolidOccupancyWithoutSnapshotDifferenceRequirement() throws IOException { public void stiltSupportUsesPlacedSolidOccupancyWithoutSnapshotDifferenceRequirement() throws IOException {
Path processor = Path.of(System.getProperty("iris.nativeStructurePostProcessorSource")); Path processor = Path.of(System.getProperty("iris.nativeStructurePostProcessorSource"));
@@ -1,11 +1,23 @@
package art.arcane.iris.nativegen; package art.arcane.iris.nativegen;
import art.arcane.iris.engine.object.IrisJigsawConfiguration; import art.arcane.iris.engine.object.IrisJigsawConfiguration;
import art.arcane.iris.engine.object.IrisStructureTerrain;
import com.mojang.datafixers.util.Either;
import com.mojang.datafixers.util.Pair;
import com.mojang.serialization.MapCodec; import com.mojang.serialization.MapCodec;
import net.minecraft.SharedConstants; import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder; import net.minecraft.core.Holder;
import net.minecraft.core.HolderOwner;
import net.minecraft.core.HolderSet; import net.minecraft.core.HolderSet;
import net.minecraft.core.Vec3i;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.IntTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap; import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.WorldGenRegion; import net.minecraft.server.level.WorldGenRegion;
import net.minecraft.util.RandomSource; import net.minecraft.util.RandomSource;
@@ -13,10 +25,12 @@ import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.LevelHeightAccessor; import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.NoiseColumn; import net.minecraft.world.level.NoiseColumn;
import net.minecraft.world.level.StructureManager; import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeManager; import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.biome.FixedBiomeSource; import net.minecraft.world.level.biome.FixedBiomeSource;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.chunk.ChunkGenerator;
@@ -25,19 +39,40 @@ import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.blending.Blender; import net.minecraft.world.level.levelgen.blending.Blender;
import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.TerrainAdjustment;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
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.StructurePoolElementType;
import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool;
import net.minecraft.world.level.levelgen.structure.pools.alias.PoolAliasBinding;
import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece; import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece;
import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure; import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure;
import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorList;
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 org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class NativeStructureFactoryTest { public class NativeStructureFactoryTest {
@BeforeClass @BeforeClass
@@ -67,20 +102,142 @@ public class NativeStructureFactoryTest {
} }
@Test @Test
public void forcedGeneratorSuppliesDeterministicPermissiveEnvironment() { public void sourceMetadataReadsScalarAndAxisSpecificJigsawRanges() {
CompoundTag scalar = new CompoundTag();
scalar.putInt("max_distance_from_center", 80);
CompoundTag axisSpecific = new CompoundTag();
CompoundTag distance = new CompoundTag();
distance.putInt("horizontal", 96);
distance.putInt("vertical", 64);
axisSpecific.put("max_distance_from_center", distance);
assertEquals(80, NativeStructureFactory.sourceDistance(scalar, "horizontal"));
assertEquals(80, NativeStructureFactory.sourceDistance(scalar, "vertical"));
assertEquals(96, NativeStructureFactory.sourceDistance(axisSpecific, "horizontal"));
assertEquals(64, NativeStructureFactory.sourceDistance(axisSpecific, "vertical"));
}
@Test
public void sourceMetadataIncludesAdjustBoundingBoxExpansion() {
Structure none = new SwampHutStructure(new Structure.StructureSettings(HolderSet.empty()));
Structure beardBox = new SwampHutStructure(new Structure.StructureSettings.Builder(HolderSet.empty())
.terrainAdapation(TerrainAdjustment.BEARD_BOX)
.build());
assertEquals(0, NativeStructureFactory.horizontalReferenceExpansion(none));
assertEquals(12, NativeStructureFactory.horizontalReferenceExpansion(beardBox));
}
@Test
public void templatePoolSpanIncludesWeightedListsFallbacksAndRotations() {
StructurePoolElement narrow = inlineElement(30, 8, 70);
StructurePoolElement wide = inlineElement(96, 8, 12);
ListPoolElement list = new ListPoolElement(
List.of(narrow, wide, EmptyPoolElement.INSTANCE),
StructureTemplatePool.Projection.RIGID);
BoundPoolHolder fallbackHolder = new BoundPoolHolder("test:fallback");
StructureTemplatePool fallback = new StructureTemplatePool(
fallbackHolder,
List.of(Pair.of(inlineElement(110, 8, 20), 1)));
fallbackHolder.bind(fallback);
BoundPoolHolder startHolder = new BoundPoolHolder("test:start");
StructureTemplatePool start = new StructureTemplatePool(
fallbackHolder,
List.of(Pair.of(list, 5), Pair.of(EmptyPoolElement.INSTANCE, 1)));
startHolder.bind(start);
assertEquals(110, NativeStructureTemplatePoolBounds.horizontalSpan(start, null));
}
@Test
public void oversizedInlineStartTemplateReportsItsFullSpan() {
BoundPoolHolder holder = new BoundPoolHolder("test:oversized");
StructureTemplatePool pool = new StructureTemplatePool(
holder,
List.of(Pair.of(inlineElement(160, 8, 9), 1)));
holder.bind(pool);
assertEquals(160, NativeStructureTemplatePoolBounds.horizontalSpan(pool, null));
}
@Test
public void sourceSpanIncludesOnlyAliasesThatCanReplaceTheStartPool() {
BoundPoolHolder emptyHolder = new BoundPoolHolder("test:empty");
StructureTemplatePool empty = new StructureTemplatePool(emptyHolder, List.of());
emptyHolder.bind(empty);
BoundPoolHolder startHolder = new BoundPoolHolder("test:start");
StructureTemplatePool start = new StructureTemplatePool(
emptyHolder, List.of(Pair.of(inlineElement(20, 8, 12), 1)));
startHolder.bind(start);
BoundPoolHolder replacementHolder = new BoundPoolHolder("test:replacement");
StructureTemplatePool replacement = new StructureTemplatePool(
emptyHolder, List.of(Pair.of(inlineElement(30, 8, 12), 1)));
replacementHolder.bind(replacement);
BoundPoolHolder unrelatedHolder = new BoundPoolHolder("test:unrelated");
StructureTemplatePool unrelated = new StructureTemplatePool(
emptyHolder, List.of(Pair.of(inlineElement(200, 8, 12), 1)));
unrelatedHolder.bind(unrelated);
ResourceKey<StructureTemplatePool> startKey = startHolder.unwrapKey().orElseThrow();
ResourceKey<StructureTemplatePool> replacementKey = replacementHolder.unwrapKey().orElseThrow();
ResourceKey<StructureTemplatePool> unrelatedKey = unrelatedHolder.unwrapKey().orElseThrow();
ResourceKey<StructureTemplatePool> childAlias = ResourceKey.create(
Registries.TEMPLATE_POOL, Identifier.parse("test:child_alias"));
List<PoolAliasBinding> aliases = List.of(
PoolAliasBinding.direct(startKey, replacementKey),
PoolAliasBinding.direct(childAlias, unrelatedKey));
Map<ResourceKey<StructureTemplatePool>, StructureTemplatePool> pools = Map.of(
replacementKey, replacement,
unrelatedKey, unrelated);
assertEquals(30, NativeStructureTemplatePoolBounds.sourceHorizontalSpan(
null, start, startKey, aliases, pools::get));
}
@Test
public void sourceStartSpanDoesNotIncludeItsFallbackPool() {
BoundPoolHolder fallbackHolder = new BoundPoolHolder("test:large_fallback");
StructureTemplatePool fallback = new StructureTemplatePool(
fallbackHolder, List.of(Pair.of(inlineElement(180, 8, 10), 1)));
fallbackHolder.bind(fallback);
BoundPoolHolder startHolder = new BoundPoolHolder("test:small_start");
StructureTemplatePool start = new StructureTemplatePool(
fallbackHolder, List.of(Pair.of(inlineElement(24, 8, 10), 1)));
startHolder.bind(start);
assertEquals(180, NativeStructureTemplatePoolBounds.horizontalSpan(start, null));
assertEquals(24, NativeStructureTemplatePoolBounds.sourceHorizontalSpan(
null, start, startHolder.unwrapKey().orElseThrow(), List.of(), ignored -> null));
}
@Test
public void customPoolElementUsesItsBoundingBoxCapability() {
BoundPoolHolder fallbackHolder = new BoundPoolHolder("test:custom_fallback");
StructureTemplatePool fallback = new StructureTemplatePool(fallbackHolder, List.of());
fallbackHolder.bind(fallback);
BoundPoolHolder poolHolder = new BoundPoolHolder("test:custom");
StructureTemplatePool pool = new StructureTemplatePool(
fallbackHolder, List.of(Pair.of(new BoundingPoolElement(37, 11), 1)));
poolHolder.bind(pool);
assertEquals(37, NativeStructureTemplatePoolBounds.horizontalSpan(pool, null));
}
@Test
public void forcedGeneratorBypassesBiomeEligibilityWithoutFabricatingTerrain() {
ChunkGenerator delegate = new TestChunkGenerator(); ChunkGenerator delegate = new TestChunkGenerator();
Holder<Biome> biome = Holder.direct((Biome) null); Holder<Biome> biome = Holder.direct((Biome) null);
LevelHeightAccessor heightAccessor = LevelHeightAccessor.create(-64, 384); LevelHeightAccessor heightAccessor = LevelHeightAccessor.create(-64, 384);
ForcedStructureChunkGenerator generator = new ForcedStructureChunkGenerator( ForcedStructureChunkGenerator generator = new ForcedStructureChunkGenerator(
delegate, biome, -20); delegate, biome);
assertEquals(-63, generator.getSeaLevel()); assertEquals(63, generator.getSeaLevel());
assertEquals(81, generator.getBaseHeight( assertEquals(64, generator.getBaseHeight(
0, 0, Heightmap.Types.WORLD_SURFACE_WG, heightAccessor, null)); 0, 0, Heightmap.Types.WORLD_SURFACE_WG, heightAccessor, null));
NoiseColumn column = generator.getBaseColumn( NoiseColumn column = generator.getBaseColumn(
0, 0, heightAccessor, null); 0, 0, heightAccessor, null);
assertSame(Blocks.STONE, column.getBlock(80).getBlock()); assertSame(Blocks.STONE, column.getBlock(80).getBlock());
assertSame(Blocks.AIR, column.getBlock(81).getBlock()); assertSame(Blocks.STONE, column.getBlock(81).getBlock());
assertNotSame(delegate.getBiomeSource(), generator.getBiomeSource());
} }
@Test @Test
@@ -103,6 +260,96 @@ public class NativeStructureFactoryTest {
assertEquals(-20, relocated.getBoundingBox().minY()); assertEquals(-20, relocated.getBoundingBox().minY());
} }
@Test
public void invalidGeneratedStartIsANormalSkippedCandidate() {
Structure source = new SwampHutStructure(
new Structure.StructureSettings(HolderSet.empty()));
StructureStart valid = new StructureStart(
source,
new ChunkPos(0, 0),
0,
new PiecesContainer(List.of(
new SwampHutPiece(RandomSource.create(23L), 0, 0)))
);
assertFalse(NativeStructureStartInjector.isUsableGeneratedStart(
StructureStart.INVALID_START));
assertFalse(NativeStructureStartInjector.isUsableGeneratedStart(null));
assertFalse(NativeStructureStartInjector.isUsableGeneratedStart(
new StructureStart(source, new ChunkPos(0, 0), 0,
new PiecesContainer(List.of()))));
assertTrue(NativeStructureStartInjector.isUsableGeneratedStart(valid));
}
@Test
public void unrepresentableGeneratedContentBecomesAnUnpublishedInvalidStart() {
Structure source = new SwampHutStructure(
new Structure.StructureSettings(HolderSet.empty()));
SwampHutPiece boundaryPiece = new SwampHutPiece(RandomSource.create(29L), 0, 0);
boundaryPiece.move(143 - boundaryPiece.getBoundingBox().maxX(), 0, 0);
StructureStart boundary = new StructureStart(
source,
new ChunkPos(0, 0),
0,
new PiecesContainer(List.of(boundaryPiece))
);
SwampHutPiece oversizedPiece = new SwampHutPiece(RandomSource.create(31L), 0, 0);
oversizedPiece.move(144 - oversizedPiece.getBoundingBox().maxX(), 0, 0);
StructureStart oversized = new StructureStart(
source,
new ChunkPos(0, 0),
0,
new PiecesContainer(List.of(oversizedPiece))
);
StructureStart boundaryResult = NativeStructureReferenceEnvelope.wrapForPublication(
boundary, source, 0, new IrisStructureTerrain(), "test:boundary_content");
StructureStart result = NativeStructureReferenceEnvelope.wrapForPublication(
oversized, source, 0,
new IrisStructureTerrain(),
"test:oversized_content");
assertTrue(boundaryResult.isValid());
assertSame(StructureStart.INVALID_START, result);
assertFalse(NativeStructureStartInjector.isUsableGeneratedStart(result));
assertEquals(1, oversized.getPieces().size());
}
@Test
public void failedManualGenerationClearsStaleOwnershipAfterReplacementHandling() throws Exception {
String source = Files.readString(
Path.of(System.getProperty("iris.nativeStructureStartInjectorSource")),
StandardCharsets.UTF_8);
int failureStart = source.indexOf("if (!isUsableGeneratedStart(generated))");
int failureEnd = source.indexOf("continue;", failureStart);
String failureBranch = source.substring(failureStart, failureEnd);
assertTrue(failureBranch.contains("if (replacement)"));
assertTrue(failureBranch.indexOf("NativeStructureOwnershipStore.discard(")
> failureBranch.lastIndexOf("}") );
assertTrue(failureStart < source.indexOf("NativeStructureOwnershipFingerprint.capture("));
assertTrue(failureStart < source.indexOf("NativeStructureOwnershipStore.record("));
}
@Test
public void failedManualStartPublicationClearsRecordedOwnershipAndPreservesTheFailure() throws Exception {
String source = Files.readString(
Path.of(System.getProperty("iris.nativeStructureStartInjectorSource")),
StandardCharsets.UTF_8);
int ownershipRecord = source.indexOf("NativeStructureOwnershipStore.record(");
int publication = source.indexOf("context.structureManager().setStartForStructure(", ownershipRecord);
int cleanup = source.indexOf("NativeStructureOwnershipStore.discard(", publication);
int suppressed = source.indexOf("publicationError.addSuppressed(cleanupError)", cleanup);
int rethrow = source.indexOf("throw publicationError;", suppressed);
assertTrue(ownershipRecord >= 0);
assertTrue(publication > ownershipRecord);
assertTrue(cleanup > publication);
assertTrue(suppressed > cleanup);
assertTrue(rethrow > suppressed);
}
private static final class TestChunkGenerator extends ChunkGenerator { private static final class TestChunkGenerator extends ChunkGenerator {
private TestChunkGenerator() { private TestChunkGenerator() {
super(new FixedBiomeSource(Holder.direct((Biome) null))); super(new FixedBiomeSource(Holder.direct((Biome) null)));
@@ -171,4 +418,91 @@ public class NativeStructureFactoryTest {
BlockPos feetPos) { BlockPos feetPos) {
} }
} }
private static StructurePoolElement inlineElement(int sizeX, int sizeY, int sizeZ) {
StructureTemplate template = new StructureTemplate();
CompoundTag tag = new CompoundTag();
ListTag size = new ListTag();
size.add(IntTag.valueOf(sizeX));
size.add(IntTag.valueOf(sizeY));
size.add(IntTag.valueOf(sizeZ));
tag.put("size", size);
template.load(BuiltInRegistries.BLOCK, tag);
return new InlinePoolElement(template);
}
private static final class InlinePoolElement extends SinglePoolElement {
private final StructureTemplate inlineTemplate;
private InlinePoolElement(StructureTemplate template) {
super(Either.right(template), Holder.direct(new StructureProcessorList(List.of())),
StructureTemplatePool.Projection.RIGID, Optional.<LiquidSettings>empty());
inlineTemplate = template;
}
@Override
public BoundingBox getBoundingBox(StructureTemplateManager templateManager,
BlockPos position, Rotation rotation) {
return inlineTemplate.getBoundingBox(
new StructurePlaceSettings().setRotation(rotation), position);
}
}
private static final class BoundingPoolElement extends StructurePoolElement {
private final int width;
private final int depth;
private BoundingPoolElement(int width, int depth) {
super(StructureTemplatePool.Projection.RIGID);
this.width = width;
this.depth = depth;
}
@Override
public Vec3i getSize(StructureTemplateManager templateManager, Rotation rotation) {
return new Vec3i(width, 1, depth);
}
@Override
public List<StructureTemplate.JigsawBlockInfo> getShuffledJigsawBlocks(
StructureTemplateManager templateManager, BlockPos position,
Rotation rotation, RandomSource random) {
return List.of();
}
@Override
public BoundingBox getBoundingBox(StructureTemplateManager templateManager,
BlockPos position, Rotation rotation) {
return new BoundingBox(
position.getX(), position.getY(), position.getZ(),
position.getX() + width - 1, position.getY(),
position.getZ() + depth - 1);
}
@Override
public boolean place(StructureTemplateManager templateManager,
WorldGenLevel world,
StructureManager structureManager, ChunkGenerator chunkGenerator,
BlockPos position, BlockPos pivot, Rotation rotation,
BoundingBox area, RandomSource random,
LiquidSettings liquidSettings, boolean keepJigsaws) {
return true;
}
@Override
public StructurePoolElementType<?> getType() {
return StructurePoolElementType.EMPTY;
}
}
private static final class BoundPoolHolder extends Holder.Reference<StructureTemplatePool> {
private BoundPoolHolder(String key) {
super(Type.STAND_ALONE, new HolderOwner<>() {
}, ResourceKey.create(Registries.TEMPLATE_POOL, Identifier.parse(key)), null);
}
private void bind(StructureTemplatePool pool) {
bindValue(pool);
}
}
} }
@@ -0,0 +1,197 @@
package art.arcane.iris.nativegen;
import net.minecraft.SharedConstants;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.ChunkPos;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class NativeStructureLocatePersistenceTest {
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void storageProbeScansEachCandidateOnlyOnce() {
AtomicInteger reads = new AtomicInteger();
CompoundTag chunk = new CompoundTag();
chunk.putInt("DataVersion", SharedConstants.getCurrentVersion().dataVersion().version());
chunk.putString("Status", "full");
CompoundTag start = new CompoundTag();
start.putString("id", "minecraft:village_plains");
start.putInt("references", 0);
CompoundTag starts = new CompoundTag();
starts.put("minecraft:village_plains", start);
CompoundTag structures = new CompoundTag();
structures.put("starts", starts);
chunk.put("structures", structures);
NativeStructureLocatePersistence.ProbeBudget budget =
new NativeStructureLocatePersistence.ProbeBudget(
512,
(chunkPos, visitor) -> {
reads.incrementAndGet();
chunk.acceptAsRoot(visitor);
return CompletableFuture.completedFuture(null);
});
ChunkPos chunkPos = new ChunkPos(7, -11);
assertTrue(budget.acceptsStored(
null, chunkPos, "minecraft:village_plains", true));
assertTrue(budget.acceptsStored(
null, chunkPos, "minecraft:village_plains", true));
assertEquals(1, reads.get());
assertEquals(1, budget.used());
}
@Test
public void legacyStoredChunkIsDatafixedBeforeItsStartIsClassified() {
AtomicBoolean datafixed = new AtomicBoolean();
CompoundTag legacy = new CompoundTag();
legacy.putInt("DataVersion", 100);
legacy.putString("Status", "full");
NativeStructureLocatePersistence.ProbeBudget budget =
new NativeStructureLocatePersistence.ProbeBudget(
512,
(chunkPos, visitor) -> {
legacy.acceptAsRoot(visitor);
return CompletableFuture.completedFuture(null);
},
(level, storedChunk) -> {
datafixed.set(true);
CompoundTag fixed = storedChunk.copy();
fixed.putInt("DataVersion",
SharedConstants.getCurrentVersion().dataVersion().version());
CompoundTag start = new CompoundTag();
start.putString("id", "minecraft:village_plains");
start.putInt("references", 1);
CompoundTag starts = new CompoundTag();
starts.put("minecraft:village_plains", start);
CompoundTag structures = new CompoundTag();
structures.put("starts", starts);
fixed.put("structures", structures);
return fixed;
});
assertFalse(budget.acceptsStored(
null, new ChunkPos(3, 4), "minecraft:village_plains", true));
assertTrue(datafixed.get());
}
@Test
public void partialDatafixFailureFallsBackToSelectedChunkVerification() {
CompoundTag legacy = new CompoundTag();
legacy.putInt("DataVersion", 100);
NativeStructureLocatePersistence.ProbeBudget budget =
new NativeStructureLocatePersistence.ProbeBudget(
512,
(chunkPos, visitor) -> {
legacy.acceptAsRoot(visitor);
return CompletableFuture.completedFuture(null);
},
(level, storedChunk) -> {
throw new IllegalStateException("broken partial datafix");
});
assertTrue(budget.acceptsStored(
null, new ChunkPos(9, -2), "minecraft:village_plains", true));
}
@Test
public void missingStartsContainerFallsBackToSelectedChunkVerification() {
CompoundTag chunk = currentChunk();
NativeStructureLocatePersistence.ProbeBudget budget = budget(chunk);
assertTrue(budget.acceptsStored(
null, new ChunkPos(5, 8), "minecraft:village_plains", false));
assertTrue(budget.acceptsStored(
null, new ChunkPos(5, 8), "minecraft:village_plains", true));
}
@Test
public void presentEmptyStartsContainerRejectsTheCandidateWithoutLoading() {
CompoundTag chunk = currentChunk();
CompoundTag structures = new CompoundTag();
structures.put("starts", new CompoundTag());
chunk.put("structures", structures);
NativeStructureLocatePersistence.ProbeBudget budget = budget(chunk);
assertFalse(budget.acceptsStored(
null, new ChunkPos(-3, 2), "minecraft:village_plains", false));
}
@Test
public void collisionTombstoneIsRejectedForNormalAndUnexploredLocate() {
CompoundTag chunk = currentChunk();
CompoundTag tombstone = new CompoundTag();
tombstone.putString("id", "INVALID");
CompoundTag starts = new CompoundTag();
starts.put("minecraft:village_plains", tombstone);
CompoundTag structures = new CompoundTag();
structures.put("starts", starts);
chunk.put("structures", structures);
ChunkPos chunkPos = new ChunkPos(12, -9);
assertFalse(budget(chunk).acceptsStored(
null, chunkPos, "minecraft:village_plains", false));
assertFalse(budget(chunk).acceptsStored(
null, chunkPos, "minecraft:village_plains", true));
}
@Test
public void malformedNonCompoundStartIsRejectedWithoutLoading() {
CompoundTag chunk = currentChunk();
CompoundTag starts = new CompoundTag();
starts.putInt("minecraft:village_plains", 1);
CompoundTag structures = new CompoundTag();
structures.put("starts", starts);
chunk.put("structures", structures);
assertFalse(budget(chunk).acceptsStored(
null, new ChunkPos(1, 1), "minecraft:village_plains", false));
}
@Test
public void referencedStoredStartIsOnlyRejectedForUnexploredLocate() {
CompoundTag chunk = currentChunk();
CompoundTag start = new CompoundTag();
start.putString("id", "minecraft:village_plains");
start.putInt("references", 1);
CompoundTag starts = new CompoundTag();
starts.put("minecraft:village_plains", start);
CompoundTag structures = new CompoundTag();
structures.put("starts", starts);
chunk.put("structures", structures);
ChunkPos chunkPos = new ChunkPos(-4, -7);
assertTrue(budget(chunk).acceptsStored(
null, chunkPos, "minecraft:village_plains", false));
assertFalse(budget(chunk).acceptsStored(
null, chunkPos, "minecraft:village_plains", true));
}
private static CompoundTag currentChunk() {
CompoundTag chunk = new CompoundTag();
chunk.putInt("DataVersion", SharedConstants.getCurrentVersion().dataVersion().version());
return chunk;
}
private static NativeStructureLocatePersistence.ProbeBudget budget(CompoundTag chunk) {
return new NativeStructureLocatePersistence.ProbeBudget(
512,
(chunkPos, visitor) -> {
chunk.acceptAsRoot(visitor);
return CompletableFuture.completedFuture(null);
});
}
}
@@ -0,0 +1,346 @@
package art.arcane.iris.nativegen;
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
import art.arcane.iris.engine.object.IrisNativeStructure;
import art.arcane.iris.engine.object.IrisStructurePlacement;
import art.arcane.iris.engine.object.IrisStructureTerrain;
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
import art.arcane.volmlib.util.collection.KList;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.HolderSet;
import net.minecraft.core.Vec3i;
import net.minecraft.nbt.ListTag;
import net.minecraft.server.Bootstrap;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.LegacyRandomSource;
import net.minecraft.world.level.levelgen.RandomSupport;
import net.minecraft.world.level.levelgen.WorldgenRandom;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement;
import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElementType;
import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool;
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces;
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure;
import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
public class NativeStructureOwnershipFingerprintTest {
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void monumentFingerprintSurvivesSourceYRegenerationWithoutSyntheticPieces() {
long seed = 8372619L;
ChunkPos origin = new ChunkPos(9, -4);
OceanMonumentStructure structure = new OceanMonumentStructure(
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
StructureStart generated = monumentStart(structure, origin, seed);
NativeStructureVerticalPlacer.alignOceanMonumentToSeaLevel(
generated, 0, 80, -64, 320);
int alignedLocatorY = NativeStructureOwnershipFingerprint.locatorY(generated);
IrisStructureTerrain terrain = new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
.setHorizontalPadding(24);
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
generated,
structure,
0,
terrain);
String beforeReload = NativeStructureOwnershipFingerprint.fingerprint(
"minecraft:monument", wrapped);
ListTag serializedPieces = (ListTag) new PiecesContainer(wrapped.getPieces()).save(null);
PiecesContainer loadedPieces = PiecesContainer.load(serializedPieces, null);
assertEquals(1, loadedPieces.pieces().size());
assertTrue(loadedPieces.pieces().getFirst()
instanceof OceanMonumentPieces.MonumentBuilding);
PiecesContainer regeneratedPieces = OceanMonumentStructure.regeneratePiecesAfterLoad(
origin, seed, loadedPieces);
StructureStart reloaded = new StructureStart(structure, origin, 0, regeneratedPieces);
String afterReload = NativeStructureOwnershipFingerprint.fingerprint(
"minecraft:monument", reloaded);
NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture(
"minecraft:monument",
wrapped,
plan(origin, NativeStructureReferenceEnvelope.contentBounds(wrapped).minY()),
NativeStructureReferenceEnvelope.referenceBounds(wrapped, structure, terrain));
assertEquals(beforeReload, afterReload);
assertTrue(NativeStructureOwnershipFingerprint.matches(ownership, reloaded));
assertNotEquals(alignedLocatorY, NativeStructureOwnershipFingerprint.locatorY(reloaded));
assertEquals(alignedLocatorY, ownership.locatorY());
assertEquals(1, reloaded.getPieces().size());
assertEquals(1, wrapped.getPieces().size());
}
@Test
public void sameKeyAndOriginStillRequiresMatchingContentIdentity() {
long seed = 18273L;
ChunkPos origin = new ChunkPos(2, 3);
OceanMonumentStructure structure = new OceanMonumentStructure(
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
StructureStart expected = monumentStart(structure, origin, seed);
StructureStart moved = monumentStart(structure, origin, seed);
for (StructurePiece piece : moved.getPieces()) {
piece.move(1, 0, 0);
}
NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture(
"minecraft:monument",
expected,
plan(origin, NativeStructureReferenceEnvelope.contentBounds(expected).minY()),
expected.getBoundingBox());
assertFalse(NativeStructureOwnershipFingerprint.matches(ownership, moved));
}
@Test
public void persistedEnvelopeRepairsReloadedMonumentReferenceOutsideLiveBounds() {
long seed = 772931L;
ChunkPos origin = new ChunkPos(4, -7);
OceanMonumentStructure structure = new OceanMonumentStructure(
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
StructureStart generated = monumentStart(structure, origin, seed);
NativeStructureVerticalPlacer.alignOceanMonumentToSeaLevel(
generated, 0, 80, -64, 320);
IrisStructureTerrain terrain = new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
.setHorizontalPadding(24);
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
generated,
structure,
0,
terrain);
NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture(
"minecraft:monument",
wrapped,
plan(origin, NativeStructureReferenceEnvelope.contentBounds(wrapped).minY()),
NativeStructureReferenceEnvelope.referenceBounds(wrapped, structure, terrain));
PiecesContainer regeneratedPieces = OceanMonumentStructure.regeneratePiecesAfterLoad(
origin, seed, new PiecesContainer(wrapped.getPieces()));
StructureStart reloaded = new StructureStart(structure, origin, 0, regeneratedPieces);
ChunkPos target = outsideLiveBounds(ownership, reloaded);
assertNotNull(target);
assertTrue(NativeStructureReferenceRepair.requiresReference(
target, "minecraft:monument", reloaded, ownership));
assertFalse(NativeStructureReferenceRepair.requiresReference(
target, "minecraft:monument", reloaded, null));
}
@Test
public void overlappingManualStartsSixteenChunksApartRemainIndependentlyReferenced() {
OceanMonumentStructure structure = new OceanMonumentStructure(
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
ChunkPos westOrigin = new ChunkPos(0, 0);
ChunkPos eastOrigin = new ChunkPos(16, 0);
StructureStart west = monumentStart(structure, westOrigin, 317L);
StructureStart east = monumentStart(structure, eastOrigin, 941L);
west.getPieces().getFirst().move(112, 0, 0);
east.getPieces().getFirst().move(-96, 0, 0);
NativeStructureOwnershipRecord westOwnership = ownership(westOrigin, west, structure);
NativeStructureOwnershipRecord eastOwnership = ownership(eastOrigin, east, structure);
ChunkPos shared = new ChunkPos(8, 0);
assertTrue(NativeStructureReferenceEnvelope.contentBounds(west).intersects(
NativeStructureReferenceEnvelope.contentBounds(east)));
assertTrue(NativeStructureReferenceRepair.requiresReference(
shared, "minecraft:monument", west, westOwnership));
assertTrue(NativeStructureReferenceRepair.requiresReference(
shared, "minecraft:monument", east, eastOwnership));
}
@Test
public void denseAdjacentManualStartsDoNotSuppressEachOther() {
OceanMonumentStructure structure = new OceanMonumentStructure(
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
ChunkPos firstOrigin = new ChunkPos(0, 0);
ChunkPos secondOrigin = new ChunkPos(1, 0);
StructureStart first = monumentStart(structure, firstOrigin, 17L);
StructureStart second = monumentStart(structure, secondOrigin, 23L);
NativeStructureOwnershipRecord firstOwnership = ownership(firstOrigin, first, structure);
NativeStructureOwnershipRecord secondOwnership = ownership(secondOrigin, second, structure);
ChunkPos shared = new ChunkPos(0, 0);
assertTrue(NativeStructureReferenceEnvelope.contentBounds(first).intersects(
NativeStructureReferenceEnvelope.contentBounds(second)));
assertTrue(NativeStructureReferenceRepair.requiresReference(
shared, "minecraft:monument", first, firstOwnership));
assertTrue(NativeStructureReferenceRepair.requiresReference(
shared, "minecraft:monument", second, secondOwnership));
}
@Test
public void customPoolElementIdentityDoesNotDependOnProcessLocalToString() {
OceanMonumentStructure structure = new OceanMonumentStructure(
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
ChunkPos origin = new ChunkPos(3, -2);
StructureStart first = poolStart(structure, origin, new IdentityStringPoolElement());
StructureStart reloaded = poolStart(structure, origin, new IdentityStringPoolElement());
assertEquals(
NativeStructureOwnershipFingerprint.fingerprint("test:custom_pool", first),
NativeStructureOwnershipFingerprint.fingerprint("test:custom_pool", reloaded));
}
@Test
public void namedPoolTemplateRemainsPartOfTheStableFingerprint() {
OceanMonumentStructure structure = new OceanMonumentStructure(
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
ChunkPos origin = new ChunkPos(3, -2);
StructurePoolElement firstElement = StructurePoolElement.single("test:first")
.apply(StructureTemplatePool.Projection.RIGID);
StructurePoolElement secondElement = StructurePoolElement.single("test:second")
.apply(StructureTemplatePool.Projection.RIGID);
assertNotEquals(
NativeStructureOwnershipFingerprint.fingerprint(
"test:named_pool", poolStart(structure, origin, firstElement)),
NativeStructureOwnershipFingerprint.fingerprint(
"test:named_pool", poolStart(structure, origin, secondElement)));
}
private static StructureStart monumentStart(OceanMonumentStructure structure,
ChunkPos origin, long seed) {
WorldgenRandom random = new WorldgenRandom(
new LegacyRandomSource(RandomSupport.generateUniqueSeed()));
random.setLargeFeatureSeed(seed, origin.x(), origin.z());
Direction orientation = Direction.Plane.HORIZONTAL.getRandomDirection(random);
OceanMonumentPieces.MonumentBuilding building = new OceanMonumentPieces.MonumentBuilding(
random,
origin.getMinBlockX() - 29,
origin.getMinBlockZ() - 29,
orientation
);
return new StructureStart(
structure,
origin,
0,
new PiecesContainer(List.of(building))
);
}
private static NativeStructureStartPlan plan(ChunkPos origin, int baseY) {
IrisNativeStructure source = new IrisNativeStructure()
.setStructure("minecraft:monument")
.setWeight(1);
IrisStructurePlacement placement = new IrisStructurePlacement()
.setPlacementId("monument-test")
.setNativeStructures(new KList<IrisNativeStructure>().qadd(source));
return new NativeStructureStartPlan(
placement,
source,
origin.x(),
origin.z(),
baseY
);
}
private static NativeStructureOwnershipRecord ownership(
ChunkPos origin, StructureStart start, OceanMonumentStructure structure) {
return NativeStructureOwnershipFingerprint.capture(
"minecraft:monument",
start,
plan(origin, NativeStructureReferenceEnvelope.contentBounds(start).minY()),
NativeStructureReferenceEnvelope.referenceBounds(
start, structure, new IrisStructureTerrain())
);
}
private static StructureStart poolStart(
OceanMonumentStructure structure, ChunkPos origin, StructurePoolElement element) {
BlockPos position = new BlockPos(
origin.getMiddleBlockX(), 64, origin.getMiddleBlockZ());
PoolElementStructurePiece piece = new PoolElementStructurePiece(
null, element, position, 0, Rotation.NONE,
new BoundingBox(position), LiquidSettings.APPLY_WATERLOGGING);
return new StructureStart(
structure, origin, 0, new PiecesContainer(List.of(piece)));
}
private static final class IdentityStringPoolElement extends StructurePoolElement {
private IdentityStringPoolElement() {
super(StructureTemplatePool.Projection.RIGID);
}
@Override
public Vec3i getSize(StructureTemplateManager templateManager, Rotation rotation) {
return new Vec3i(1, 1, 1);
}
@Override
public List<StructureTemplate.JigsawBlockInfo> getShuffledJigsawBlocks(
StructureTemplateManager templateManager, BlockPos position,
Rotation rotation, RandomSource random) {
return List.of();
}
@Override
public BoundingBox getBoundingBox(StructureTemplateManager templateManager,
BlockPos position, Rotation rotation) {
return new BoundingBox(position);
}
@Override
public boolean place(StructureTemplateManager templateManager,
WorldGenLevel world, StructureManager structureManager,
ChunkGenerator chunkGenerator, BlockPos position, BlockPos pivot,
Rotation rotation, BoundingBox area, RandomSource random,
LiquidSettings liquidSettings, boolean keepJigsaws) {
return true;
}
@Override
public StructurePoolElementType<?> getType() {
return StructurePoolElementType.EMPTY;
}
@Override
public String toString() {
return "IdentityStringPoolElement@"
+ Integer.toHexString(System.identityHashCode(this));
}
}
private static ChunkPos outsideLiveBounds(NativeStructureOwnershipRecord ownership,
StructureStart start) {
for (int chunkX = ownership.referenceMinChunkX();
chunkX <= ownership.referenceMaxChunkX(); chunkX++) {
for (int chunkZ = ownership.referenceMinChunkZ();
chunkZ <= ownership.referenceMaxChunkZ(); chunkZ++) {
ChunkPos candidate = new ChunkPos(chunkX, chunkZ);
if (!start.getBoundingBox().intersects(
candidate.getMinBlockX(), candidate.getMinBlockZ(),
candidate.getMaxBlockX(), candidate.getMaxBlockZ())) {
return candidate;
}
}
}
return null;
}
}
@@ -0,0 +1,185 @@
package art.arcane.iris.nativegen;
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
import art.arcane.iris.engine.framework.StructurePlacementGrid;
import art.arcane.iris.engine.object.IrisNativeStructure;
import art.arcane.iris.engine.object.IrisStructurePlacement;
import art.arcane.iris.engine.object.IrisStructureTerrain;
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
import art.arcane.volmlib.util.collection.KList;
import net.minecraft.SharedConstants;
import net.minecraft.core.Direction;
import net.minecraft.core.HolderSet;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.levelgen.LegacyRandomSource;
import net.minecraft.world.level.levelgen.RandomSupport;
import net.minecraft.world.level.levelgen.WorldgenRandom;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces;
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class NativeStructureOwnershipRecoveryTest {
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void missingSidecarRecoversExactPolicyEnvelopeAndLocatorAfterReload() {
long seed = 5519231L;
ChunkPos origin = new ChunkPos(7, -5);
OceanMonumentStructure structure = structure();
StructureStart expected = monumentStart(structure, origin, seed);
NativeStructureVerticalPlacer.alignOceanMonumentToSeaLevel(
expected, 0, 80, -64, 320);
int expectedLocatorY = NativeStructureOwnershipFingerprint.locatorY(expected);
NativeStructureStartPlan plan = plan(origin, "recovery-exact", 24);
StructureStart reloaded = new StructureStart(
structure,
origin,
expected.getReferences(),
OceanMonumentStructure.regeneratePiecesAfterLoad(
origin, seed, new PiecesContainer(expected.getPieces()))
);
NativeStructureOwnershipRecord recovered =
NativeStructureOwnershipRecovery.proveCandidate(
"minecraft:monument", structure, reloaded,
plan, expected, false);
assertNotNull(recovered);
assertEquals(StructurePlacementGrid.placementIdentity(plan.placement()),
recovered.placementIdentity());
assertEquals(expectedLocatorY, recovered.locatorY());
assertNotEquals(NativeStructureOwnershipFingerprint.locatorY(reloaded),
recovered.locatorY());
assertEquals(IrisStructureTerrainMode.FORCE_CARVE,
recovered.restoredDecision().terrain().resolvedMode());
assertEquals(24, recovered.restoredDecision().terrain().getHorizontalPadding());
assertTrue(recovered.referenceMinChunkX()
< NativeStructureReferenceEnvelope.contentBounds(reloaded).minX() >> 4);
}
@Test
public void naturalSameKeyStartRemainsUnownedWhenOriginIsAmbiguous() {
long seed = 22817L;
ChunkPos origin = new ChunkPos(-3, 6);
OceanMonumentStructure structure = structure();
StructureStart natural = monumentStart(structure, origin, seed);
NativeStructureStartPlan plan = plan(origin, "natural-ambiguous", 0);
NativeStructureOwnershipRecord recovered =
NativeStructureOwnershipRecovery.proveCandidate(
"minecraft:monument", structure, natural,
plan, natural, true);
assertNull(recovered);
}
@Test
public void changedPlacementIdentityOrGeometryCannotRecoverOwnership() {
long seed = 991723L;
ChunkPos origin = new ChunkPos(2, 4);
OceanMonumentStructure structure = structure();
StructureStart expected = monumentStart(structure, origin, seed);
StructureStart changedGeometry = monumentStart(structure, origin, seed);
for (StructurePiece piece : changedGeometry.getPieces()) {
piece.move(1, 0, 0);
}
NativeStructureStartPlan originalIdentity = plan(
origin, "original-identity", 0);
NativeStructureStartPlan changedIdentity = plan(
new ChunkPos(origin.x() + 1, origin.z()), "changed-identity", 0);
assertNotEquals(
StructurePlacementGrid.placementIdentity(originalIdentity.placement()),
StructurePlacementGrid.placementIdentity(changedIdentity.placement()));
assertNull(NativeStructureOwnershipRecovery.proveCandidate(
"minecraft:monument", structure, expected,
changedIdentity, expected, false));
assertNull(NativeStructureOwnershipRecovery.proveCandidate(
"minecraft:monument", structure, changedGeometry,
plan(origin, "geometry-check", 0), expected, false));
}
@Test
public void recoveredEnvelopeStillRequiresExactFingerprint() {
long seed = 81337L;
ChunkPos origin = new ChunkPos(-8, -9);
OceanMonumentStructure structure = structure();
StructureStart expected = monumentStart(structure, origin, seed);
NativeStructureOwnershipRecord recovered =
NativeStructureOwnershipRecovery.proveCandidate(
"minecraft:monument", structure, expected,
plan(origin, "fingerprint-check", 16), expected, false);
assertNotNull(recovered);
StructureStart moved = monumentStart(structure, origin, seed);
for (StructurePiece piece : moved.getPieces()) {
piece.move(0, 0, 1);
}
assertFalse(NativeStructureOwnershipFingerprint.matches(recovered, moved));
}
private static OceanMonumentStructure structure() {
return new OceanMonumentStructure(
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
}
private static StructureStart monumentStart(OceanMonumentStructure structure,
ChunkPos origin, long seed) {
WorldgenRandom random = new WorldgenRandom(
new LegacyRandomSource(RandomSupport.generateUniqueSeed()));
random.setLargeFeatureSeed(seed, origin.x(), origin.z());
Direction orientation = Direction.Plane.HORIZONTAL.getRandomDirection(random);
OceanMonumentPieces.MonumentBuilding building =
new OceanMonumentPieces.MonumentBuilding(
random,
origin.getMinBlockX() - 29,
origin.getMinBlockZ() - 29,
orientation
);
return new StructureStart(
structure, origin, 0, new PiecesContainer(List.of(building)));
}
private static NativeStructureStartPlan plan(ChunkPos origin,
String placementId,
int horizontalPadding) {
IrisNativeStructure source = new IrisNativeStructure()
.setStructure("minecraft:monument")
.setWeight(1);
IrisStructureTerrain terrain = new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
.setHorizontalPadding(horizontalPadding);
IrisStructurePlacement placement = new IrisStructurePlacement()
.setPlacementId(placementId)
.setTerrain(terrain)
.setNativeStructures(new KList<IrisNativeStructure>().qadd(source));
return new NativeStructureStartPlan(
placement,
source,
origin.x(),
origin.z(),
NativeStructureReferenceEnvelope.contentBounds(
monumentStart(structure(), origin, 1L)).minY()
);
}
}
@@ -7,22 +7,35 @@ import art.arcane.iris.engine.object.IrisStructureYBand;
import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.RNG;
import net.minecraft.SharedConstants; import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet; import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap; import net.minecraft.server.Bootstrap;
import net.minecraft.util.RandomSource; import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.dimension.BuiltinDimensionTypes;
import net.minecraft.world.level.levelgen.GenerationStep; import net.minecraft.world.level.levelgen.GenerationStep;
import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructurePiece; import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; import net.minecraft.world.level.levelgen.structure.TerrainAdjustment;
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
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.structures.DesertPyramidPiece; import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece;
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure; import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure;
import net.minecraft.world.level.levelgen.structure.templatesystem.BlockIgnoreProcessor;
import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorList;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
@@ -31,14 +44,16 @@ import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy; import java.lang.reflect.Proxy;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.BitSet;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class NativeStructurePostProcessorEncaseTest { public class NativeStructurePostProcessorEncaseTest {
@@ -98,6 +113,96 @@ public class NativeStructurePostProcessorEncaseTest {
state(blocks, bounds.minX(), 0, bounds.minZ())); state(blocks, bounds.minX(), 0, bounds.minZ()));
} }
@Test
public void defaultEncaseMaterialFollowsVanillaDimensionTerrain() {
assertEquals(Blocks.NETHERRACK.defaultBlockState(),
NativeStructureTerrainIntegrator.defaultEncaseBlock(Level.NETHER, 64));
assertEquals(Blocks.END_STONE.defaultBlockState(),
NativeStructureTerrainIntegrator.defaultEncaseBlock(Level.END, 64));
assertEquals(Blocks.STONE.defaultBlockState(),
NativeStructureTerrainIntegrator.defaultEncaseBlock(Level.OVERWORLD, 64));
}
@Test
public void sourceEncaseMaterialFollowsNearbyIrisTerrain() {
Map<BlockPos, BlockState> blocks = new HashMap<>();
BlockPos fill = new BlockPos(4, 64, 8);
put(blocks, fill.getX() + 3, fill.getY(), fill.getZ(), Blocks.TUFF.defaultBlockState());
assertEquals(Blocks.TUFF.defaultBlockState(),
NativeStructureTerrainIntegrator.sourceEncaseBlock(world(blocks), fill));
}
@Test
public void sourceMaterialSnapshotIgnoresPreviouslyDecoratedAdjacentChunks() {
Map<BlockPos, BlockState> blocks = new HashMap<>();
BlockPos fill = new BlockPos(0, 64, 8);
put(blocks, -1, 64, 8, Blocks.OAK_PLANKS.defaultBlockState());
put(blocks, 3, 64, 8, Blocks.TUFF.defaultBlockState());
WorldGenLevel world = world(blocks);
NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceTerrain =
sourceSnapshot(world, new BoundingBox(0, 64, 0, 15, 64, 15));
assertEquals(Blocks.TUFF.defaultBlockState(),
NativeStructureTerrainIntegrator.sourceEncaseBlock(
world, fill, sourceTerrain));
}
@Test
public void sourceMaterialSnapshotIsStableAcrossEarlierFillsInTheSameChunk() {
Map<BlockPos, BlockState> blocks = new HashMap<>();
BlockPos fill = new BlockPos(0, 64, 8);
put(blocks, 3, 64, 8, Blocks.TUFF.defaultBlockState());
WorldGenLevel world = world(blocks);
NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceTerrain =
sourceSnapshot(world, new BoundingBox(0, 64, 0, 15, 64, 15));
put(blocks, 1, 64, 8, Blocks.OAK_PLANKS.defaultBlockState());
assertEquals(Blocks.TUFF.defaultBlockState(),
NativeStructureTerrainIntegrator.sourceEncaseBlock(
world, fill, sourceTerrain));
}
@Test
public void sourceMaterialRejectsStructureBlocksAndOresBeforeDimensionFallback() {
Map<BlockPos, BlockState> blocks = new HashMap<>();
BlockPos fill = new BlockPos(4, -20, 8);
put(blocks, 3, -20, 8, Blocks.OAK_PLANKS.defaultBlockState());
put(blocks, 5, -20, 8, Blocks.STONE_BRICKS.defaultBlockState());
put(blocks, 4, -20, 7, Blocks.IRON_ORE.defaultBlockState());
assertEquals(Blocks.DEEPSLATE.defaultBlockState(),
NativeStructureTerrainIntegrator.sourceEncaseBlock(world(blocks), fill));
}
@Test
public void sourceMaterialSnapshotOnlyAllocatesActualFillLayers() {
StructureStart start = start(TerrainAdjustment.BURY, 64);
BoundingBox area = new BoundingBox(0, -2032, 0, 15, 2030, 15);
NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceTerrain =
NativeStructureTerrainIntegrator.captureSourceTerrain(
world(new HashMap<>()), area,
List.of(new NativeStructureTerrainIntegrator.TerrainTarget(
"test:bounded-snapshot", start,
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))));
assertNotNull(sourceTerrain);
assertEquals(23 * 16 * 16, sourceTerrain.sampledCells());
}
@Test
public void customDimensionKeyUsesRegisteredVanillaDimensionType() {
ResourceKey<Level> customDimension = ResourceKey.create(
Registries.DIMENSION, Identifier.parse("iris:custom_nether"));
assertEquals(Blocks.NETHERRACK.defaultBlockState(),
NativeStructureTerrainIntegrator.defaultEncaseBlock(
customDimension, BuiltinDimensionTypes.NETHER, null, 64));
assertEquals(Blocks.END_STONE.defaultBlockState(),
NativeStructureTerrainIntegrator.defaultEncaseBlock(
customDimension, BuiltinDimensionTypes.END, null, 64));
}
@Test @Test
public void configuredEncasePaletteReplacesTheDefaultMaterial() { public void configuredEncasePaletteReplacesTheDefaultMaterial() {
StructureStart start = start(TerrainAdjustment.BURY, 64); StructureStart start = start(TerrainAdjustment.BURY, 64);
@@ -129,58 +234,170 @@ public class NativeStructurePostProcessorEncaseTest {
} }
@Test @Test
public void buryAndEncapsulateAdaptationsAutoDefaultToEncase() { public void omittedTerrainConfigurationRetainsSourceSemantics() {
for (TerrainAdjustment adjustment : List.of(TerrainAdjustment.BURY, TerrainAdjustment.ENCAPSULATE)) { for (TerrainAdjustment adjustment : TerrainAdjustment.values()) {
IrisStructureTerrain resolved = NativeStructureTerrainIntegrator.resolveNativeTerrain( IrisStructureTerrain resolved = NativeStructureTerrainIntegrator.resolveNativeTerrain(
start(adjustment, 64), null); start(adjustment, 64), null);
assertEquals(IrisStructureTerrainMode.ENCASE, resolved.resolvedMode()); assertEquals(IrisStructureTerrainMode.SOURCE, resolved.resolvedMode());
assertEquals(3, resolved.getHorizontalPadding());
assertEquals(3, resolved.getCeilingPadding());
assertEquals(3, resolved.getFloorPadding());
assertNull(resolved.getEncasePalette());
} }
} }
@Test @Test
public void otherAdaptationsNeverAutoDefaultToEncase() { public void sourceBuryUsesTheVanillaSixByTwelveInfluenceEnvelope() {
for (TerrainAdjustment adjustment : List.of( StructureStart start = start(TerrainAdjustment.BURY, 64);
TerrainAdjustment.NONE, TerrainAdjustment.BEARD_THIN, TerrainAdjustment.BEARD_BOX)) { BoundingBox bounds = start.getPieces().getFirst().getBoundingBox();
assertNull(NativeStructureTerrainIntegrator.resolveNativeTerrain( int groundY = bounds.minY();
start(adjustment, 64), null)); BoundingBox area = new BoundingBox(
} bounds.minX() - 7, groundY - 13, bounds.minZ(),
bounds.maxX(), groundY + 13, bounds.maxZ());
Map<BlockPos, BlockState> blocks = new HashMap<>();
NativeStructureTerrainIntegrator.integrateTerrain(
world(blocks), area, "test:bury", start,
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE), null);
assertEquals(Blocks.STONE.defaultBlockState(),
state(blocks, bounds.minX() - 5, groundY, bounds.minZ()));
assertEquals(Blocks.STONE.defaultBlockState(),
state(blocks, bounds.minX(), groundY + 11, bounds.minZ()));
assertEquals(Blocks.AIR.defaultBlockState(),
state(blocks, bounds.minX() - 6, groundY, bounds.minZ()));
assertEquals(Blocks.AIR.defaultBlockState(),
state(blocks, bounds.minX(), groundY + 12, bounds.minZ()));
assertEquals(Blocks.AIR.defaultBlockState(),
state(blocks, bounds.minX() - 7, groundY, bounds.minZ()));
assertEquals(Blocks.AIR.defaultBlockState(),
state(blocks, bounds.minX(), groundY + 13, bounds.minZ()));
}
@Test
public void sourceEncapsulateWrapsTheFullPieceBounds() {
StructureStart start = start(TerrainAdjustment.ENCAPSULATE, 64);
BoundingBox bounds = start.getPieces().getFirst().getBoundingBox();
BoundingBox area = new BoundingBox(
bounds.minX() - 13, bounds.minY(), bounds.minZ(),
bounds.maxX(), bounds.maxY() + 13, bounds.maxZ());
Map<BlockPos, BlockState> blocks = new HashMap<>();
NativeStructureTerrainIntegrator.integrateTerrain(
world(blocks), area, "test:encapsulate", start,
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE), null);
assertEquals(Blocks.STONE.defaultBlockState(),
state(blocks, bounds.minX() - 11, bounds.minY(), bounds.minZ()));
assertEquals(Blocks.STONE.defaultBlockState(),
state(blocks, bounds.minX(), bounds.maxY() + 11, bounds.minZ()));
assertEquals(Blocks.AIR.defaultBlockState(),
state(blocks, bounds.minX() - 12, bounds.minY(), bounds.minZ()));
assertEquals(Blocks.AIR.defaultBlockState(),
state(blocks, bounds.minX(), bounds.maxY() + 12, bounds.minZ()));
assertEquals(Blocks.AIR.defaultBlockState(),
state(blocks, bounds.minX() - 13, bounds.minY(), bounds.minZ()));
assertEquals(Blocks.AIR.defaultBlockState(),
state(blocks, bounds.minX(), bounds.maxY() + 13, bounds.minZ()));
}
@Test
public void sourceTerrainDensityOnlyUsesRigidPoolPieces() {
PoolElementStructurePiece terrainMatching = poolPiece(StructureTemplatePool.Projection.TERRAIN_MATCHING);
PoolElementStructurePiece rigid = poolPiece(StructureTemplatePool.Projection.RIGID);
assertFalse(NativeStructureTerrainIntegrator.isSourceRigidPiece(terrainMatching));
assertTrue(NativeStructureTerrainIntegrator.isSourceRigidPiece(rigid));
}
@Test
public void sourceJunctionTerrainUsesTheStrictLowerTwelveBlockEnvelope() {
assertTrue(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(0, -1, 0));
assertTrue(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(0, -11, 0));
assertFalse(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(0, 0, 0));
assertFalse(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(0, -12, 0));
assertFalse(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(12, -1, 0));
} }
@Test @Test
public void explicitTerrainConfigurationWinsOverAutoEncase() { public void explicitTerrainConfigurationWinsOverAutoEncase() {
IrisStructureTerrain configured = new IrisStructureTerrain() IrisStructureTerrain configured = new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.SOURCE); .setMode(IrisStructureTerrainMode.PRESERVE);
assertSame(configured, NativeStructureTerrainIntegrator.resolveNativeTerrain( assertSame(configured, NativeStructureTerrainIntegrator.resolveNativeTerrain(
start(TerrainAdjustment.BURY, 64), configured)); start(TerrainAdjustment.BURY, 64), configured));
} }
@Test
public void legacyTemplateAirIsClearedAfterEveryTerrainFillPath() {
IrisStructureTerrain source = new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.SOURCE);
IrisStructureTerrain preserve = new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.PRESERVE);
IrisStructureTerrain encase = new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.ENCASE);
assertTrue(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir(
start(TerrainAdjustment.BURY, 64), source));
assertTrue(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir(
start(TerrainAdjustment.BEARD_BOX, 64), source));
assertTrue(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir(
start(TerrainAdjustment.NONE, 64), encase));
assertFalse(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir(
start(TerrainAdjustment.NONE, 64), source));
assertFalse(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir(
start(TerrainAdjustment.BURY, 64), preserve));
}
@Test @Test
public void encaseReservesTheNeighborChunkEnvelope() { public void encaseReservesTheNeighborChunkEnvelope() {
StructureStart generated = start(TerrainAdjustment.BURY, 64); StructureStart generated = start(TerrainAdjustment.BURY, 64);
BoundingBox content = generated.getBoundingBox(); BoundingBox sourceBounds = generated.getBoundingBox();
BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(generated);
IrisStructureTerrain terrain = new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.ENCASE)
.setHorizontalPadding(4);
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
generated, generated,
generated.getStructure(), generated.getStructure(),
0, 0,
null, terrain);
new IrisStructureTerrain() BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds(
.setMode(IrisStructureTerrainMode.ENCASE) wrapped, wrapped.getStructure(), terrain);
.setHorizontalPadding(4));
assertEquals(content.minX() - 4, wrapped.getBoundingBox().minX()); assertEquals(sourceBounds, wrapped.getBoundingBox());
assertEquals(content.maxX() + 4, wrapped.getBoundingBox().maxX()); assertEquals(content.minX() - 4, referenceBounds.minX());
assertEquals(content.minZ() - 4, wrapped.getBoundingBox().minZ()); assertEquals(content.maxX() + 4, referenceBounds.maxX());
assertEquals(content.maxZ() + 4, wrapped.getBoundingBox().maxZ()); assertEquals(content.minZ() - 4, referenceBounds.minZ());
assertEquals(2, wrapped.getPieces().stream() assertEquals(content.maxZ() + 4, referenceBounds.maxZ());
.filter(NativeStructureReferenceEnvelope::isMarker) assertEquals(generated.getPieces().size(), wrapped.getPieces().size());
.count()); }
@Test
public void referenceEnvelopeRejectsChunkCoordinateOverflow() {
StructureStart generated = start(TerrainAdjustment.NONE, 64);
StructureStart extreme = new StructureStart(
generated.getStructure(), new ChunkPos(Integer.MAX_VALUE, 0), 0,
new PiecesContainer(generated.getPieces()));
assertThrows(IllegalStateException.class, () ->
NativeStructureReferenceEnvelope.referenceBounds(
extreme, extreme.getStructure(), new IrisStructureTerrain()));
}
@Test
public void explicitTerrainEnvelopeDoesNotInheritSourceAdjustment() {
StructureStart generated = start(TerrainAdjustment.BURY, 64);
BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(generated);
IrisStructureTerrain terrain = new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.ENCASE)
.setHorizontalPadding(112);
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
generated, generated.getStructure(), 0, terrain);
BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds(
wrapped, wrapped.getStructure(), terrain);
assertEquals(content.minX() - 112, referenceBounds.minX());
assertEquals(content.maxX() + 112, referenceBounds.maxX());
} }
@Test @Test
@@ -259,6 +476,24 @@ public class NativeStructurePostProcessorEncaseTest {
structure, new ChunkPos(0, 0), 0, new PiecesContainer(List.of(piece))); structure, new ChunkPos(0, 0), 0, new PiecesContainer(List.of(piece)));
} }
private static PoolElementStructurePiece poolPiece(StructureTemplatePool.Projection projection) {
BlockPos position = new BlockPos(0, 64, 0);
StructurePoolElement element = StructurePoolElement.single(
"minecraft:empty",
Holder.direct(new StructureProcessorList(List.of(
BlockIgnoreProcessor.STRUCTURE_AND_AIR))),
LiquidSettings.APPLY_WATERLOGGING
).apply(projection);
return new PoolElementStructurePiece(
null,
element,
position,
0,
Rotation.NONE,
new BoundingBox(position),
LiquidSettings.APPLY_WATERLOGGING);
}
private static WorldGenLevel world(Map<BlockPos, BlockState> blocks) { private static WorldGenLevel world(Map<BlockPos, BlockState> blocks) {
InvocationHandler handler = (proxy, method, arguments) -> { InvocationHandler handler = (proxy, method, arguments) -> {
String methodName = method.getName(); String methodName = method.getName();
@@ -275,6 +510,9 @@ public class NativeStructurePostProcessorEncaseTest {
if (methodName.equals("getSeed")) { if (methodName.equals("getSeed")) {
return TEST_SEED; return TEST_SEED;
} }
if (methodName.equals("getLevel")) {
return null;
}
if (methodName.equals("hashCode")) { if (methodName.equals("hashCode")) {
return System.identityHashCode(proxy); return System.identityHashCode(proxy);
} }
@@ -290,6 +528,14 @@ public class NativeStructurePostProcessorEncaseTest {
WorldGenLevel.class.getClassLoader(), new Class<?>[]{WorldGenLevel.class}, handler); WorldGenLevel.class.getClassLoader(), new Class<?>[]{WorldGenLevel.class}, handler);
} }
private static NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceSnapshot(
WorldGenLevel world, BoundingBox area) {
BitSet requiredLayers = new BitSet(area.getYSpan());
requiredLayers.set(0, area.getYSpan());
return NativeStructureTerrainIntegrator.SourceTerrainSnapshot.capture(
world, area, requiredLayers);
}
private static void put(Map<BlockPos, BlockState> blocks, private static void put(Map<BlockPos, BlockState> blocks,
int x, int y, int z, BlockState state) { int x, int y, int z, BlockState state) {
blocks.put(new BlockPos(x, y, z), state); blocks.put(new BlockPos(x, y, z), state);
@@ -1,5 +1,7 @@
package art.arcane.iris.nativegen; package art.arcane.iris.nativegen;
import art.arcane.iris.engine.object.IrisStructureTerrain;
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
import net.minecraft.SharedConstants; import net.minecraft.SharedConstants;
import net.minecraft.core.Direction; import net.minecraft.core.Direction;
import net.minecraft.core.HolderSet; import net.minecraft.core.HolderSet;
@@ -16,7 +18,12 @@ import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStru
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
@@ -80,6 +87,25 @@ public class NativeStructurePostProcessorMonumentTest {
assertEquals(51, start.getBoundingBox().maxY()); assertEquals(51, start.getBoundingBox().maxY());
} }
@Test
public void referenceEnvelopeDoesNotAddMonumentPieces() {
StructureStart start = monumentStart(1337L);
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
start,
start.getStructure(),
0,
new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
.setHorizontalPadding(4));
int offset = NativeStructureVerticalPlacer.applyVerticalPlacement(
wrapped, "minecraft:monument", 0, 63, -64, 320,
false, false, null, (x, z) -> 0);
assertEquals(0, offset);
assertEquals(1, wrapped.getPieces().size());
}
@Test @Test
public void vanillaReloadRegenerationIsRealignedBeforePlacement() { public void vanillaReloadRegenerationIsRealignedBeforePlacement() {
long seed = 1337L; long seed = 1337L;
@@ -101,6 +127,42 @@ public class NativeStructurePostProcessorMonumentTest {
assertEquals(48, reloaded.getBoundingBox().maxY()); assertEquals(48, reloaded.getBoundingBox().maxY());
} }
@Test
public void neighboringChunkPlacementRealignsReloadedMonumentOnce() throws Exception {
long seed = 1337L;
ChunkPos chunkPos = new ChunkPos(0, 0);
StructureStart initial = monumentStart(seed);
NativeStructureVerticalPlacer.applyVerticalPlacement(
initial, "minecraft:monument", 0, 50, -256, 512,
false, false, null, (x, z) -> 0);
PiecesContainer regenerated = OceanMonumentStructure.regeneratePiecesAfterLoad(
chunkPos, seed, new PiecesContainer(initial.getPieces()));
StructureStart reloaded = new StructureStart(
monumentStructure(), chunkPos, 0, regenerated);
ExecutorService executor = Executors.newFixedThreadPool(8);
CountDownLatch release = new CountDownLatch(1);
List<Future<?>> futures = new ArrayList<>();
try {
for (int task = 0; task < 16; task++) {
futures.add(executor.submit(() -> {
release.await();
NativeStructureVerticalPlacer.ensureMonumentSeaLevelAlignment(
reloaded, "minecraft:monument", 0, 50, -256, 512);
return null;
}));
}
release.countDown();
for (Future<?> future : futures) {
future.get();
}
} finally {
executor.shutdownNow();
}
assertEquals(26, reloaded.getBoundingBox().minY());
assertEquals(48, reloaded.getBoundingBox().maxY());
}
@Test @Test
public void impossibleSeaLevelAlignmentFailsInsteadOfClippingTheMonument() { public void impossibleSeaLevelAlignmentFailsInsteadOfClippingTheMonument() {
StructureStart start = monumentStart(1337L); StructureStart start = monumentStart(1337L);
@@ -135,6 +135,27 @@ public class NativeStructurePostProcessorScatteredFeatureTest {
assertTrue(field.trySetAccessible()); assertTrue(field.trySetAccessible());
} }
@Test
public void datapackOverridesOfSpecialVanillaIdsUseGenericPlacement() {
StructureStart pyramidOverride = swampStart(
new SwampHutPiece(RandomSource.create(43L), 0, 0));
int pyramidMinY = pyramidOverride.getBoundingBox().minY();
int pyramidOffset = NativeStructureVerticalPlacer.applyVerticalPlacement(
pyramidOverride, "minecraft:desert_pyramid", 5,
63, -64, 320, false, false, null, (x, z) -> 200);
assertEquals(5, pyramidOffset);
assertEquals(pyramidMinY + 5, pyramidOverride.getBoundingBox().minY());
StructureStart monumentOverride = swampStart(
new SwampHutPiece(RandomSource.create(47L), 0, 0));
int monumentMinY = monumentOverride.getBoundingBox().minY();
NativeStructureVerticalPlacer.ensureMonumentSeaLevelAlignment(
monumentOverride, "minecraft:monument", 0, 40, -64, 320);
assertEquals(monumentMinY, monumentOverride.getBoundingBox().minY());
}
private static StructureStart desertStart(StructurePiece piece) { private static StructureStart desertStart(StructurePiece piece) {
Structure structure = new DesertPyramidStructure(new Structure.StructureSettings(HolderSet.empty())); Structure structure = new DesertPyramidStructure(new Structure.StructureSettings(HolderSet.empty()));
return start(structure, piece); return start(structure, piece);
@@ -5,23 +5,41 @@ import art.arcane.iris.engine.object.IrisStructureCarveShape;
import art.arcane.iris.engine.object.IrisStructureTerrain; import art.arcane.iris.engine.object.IrisStructureTerrain;
import art.arcane.iris.engine.object.IrisStructureTerrainMode; import art.arcane.iris.engine.object.IrisStructureTerrainMode;
import com.mojang.datafixers.util.Either; import com.mojang.datafixers.util.Either;
import com.mojang.serialization.Codec;
import net.minecraft.SharedConstants; import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet; import net.minecraft.core.HolderSet;
import net.minecraft.core.IdMapper;
import net.minecraft.server.Bootstrap; import net.minecraft.server.Bootstrap;
import net.minecraft.util.RandomSource; import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.WorldGenLevel; 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.Blocks;
import net.minecraft.world.level.block.Rotation; import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.block.state.BlockState; 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.GenerationStep;
import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; import net.minecraft.world.level.levelgen.structure.TerrainAdjustment;
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement; import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement;
import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement;
import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool;
import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings;
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece; import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece;
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure; import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings; import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
@@ -35,15 +53,22 @@ import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Modifier; import java.lang.reflect.Modifier;
import java.lang.reflect.Proxy; import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class NativeStructurePostProcessorSurfaceTerrainTest { public class NativeStructurePostProcessorSurfaceTerrainTest {
@@ -60,34 +85,148 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
} }
@Test @Test
public void onlySurfaceBeardThinStructuresPrepareTerrain() { public void sourceBeardAdjustmentsPrepareTerrainAcrossDeclaredSteps() {
assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.SURFACE_STRUCTURES)); TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.SURFACE_STRUCTURES));
assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
TerrainAdjustment.BURY, GenerationStep.Decoration.SURFACE_STRUCTURES)); TerrainAdjustment.BEARD_BOX, GenerationStep.Decoration.FLUID_SPRINGS));
assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
TerrainAdjustment.BEARD_BOX, GenerationStep.Decoration.SURFACE_STRUCTURES)); TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.UNDERGROUND_STRUCTURES));
assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
TerrainAdjustment.ENCAPSULATE, GenerationStep.Decoration.SURFACE_STRUCTURES));
} }
@Test @Test
public void undergroundAdjustmentsNeverPrepareSurfaceTerrain() { public void nonBeardAdjustmentsDoNotPrepareSurfaceTerrain() {
for (TerrainAdjustment adjustment : List.of( assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
TerrainAdjustment.BEARD_THIN, TerrainAdjustment.BURY, GenerationStep.Decoration.SURFACE_STRUCTURES));
TerrainAdjustment.BURY, assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
TerrainAdjustment.BEARD_BOX, TerrainAdjustment.ENCAPSULATE, GenerationStep.Decoration.SURFACE_STRUCTURES));
TerrainAdjustment.ENCAPSULATE)) { assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain( TerrainAdjustment.NONE, GenerationStep.Decoration.FLUID_SPRINGS));
adjustment, GenerationStep.Decoration.UNDERGROUND_STRUCTURES)); }
@Test
public void explicitTerrainOverrideDisablesSourceBeardFitting() {
StructureStart start = desertStart(TerrainAdjustment.BEARD_BOX);
assertTrue(NativeStructureSurfaceFitter.requiresSurfaceTerrain(
new NativeStructureTerrainIntegrator.TerrainTarget(
"test:beard_box", start,
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))));
assertFalse(NativeStructureSurfaceFitter.requiresSurfaceTerrain(
new NativeStructureTerrainIntegrator.TerrainTarget(
"test:beard_box", start,
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.PRESERVE))));
}
@Test
public void beardBoxUsesTheFullRigidHeightWhileBeardThinUsesTheGroundPlane() {
NativeStructureSurfaceFitter.SurfaceAnchor thin =
new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, 64, 2);
NativeStructureSurfaceFitter.SurfaceAnchor box =
new NativeStructureSurfaceFitter.SurfaceAnchor(
0, 4, 0, 4, 64, 2, 65, 90);
assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(thin), 2, 2, 80));
assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(box), 2, 2, 80));
}
@Test
public void beardInfluenceBeginsAtGroundYRatherThanTheSurfaceBlock() {
BoundingBox bounds = new BoundingBox(0, 64, 0, 4, 90, 4);
NativeStructureSurfaceFitter.SurfaceAnchor thin =
NativeStructureSurfaceFitter.surfaceAnchor(
bounds, 68, 2, TerrainAdjustment.BEARD_THIN);
NativeStructureSurfaceFitter.SurfaceAnchor box =
NativeStructureSurfaceFitter.surfaceAnchor(
bounds, 68, 2, TerrainAdjustment.BEARD_BOX);
assertEquals(67, thin.meetY());
assertEquals(0, thin.verticalDistance(68));
assertEquals(1, thin.verticalDistance(67));
assertEquals(0, box.verticalDistance(68));
assertEquals(1, box.verticalDistance(67));
assertEquals(0, box.verticalDistance(90));
assertEquals(1, box.verticalDistance(91));
}
@Test
public void rigidFootprintsRaiseTerrainAcrossGapsLargerThanTheBeardKernel() {
NativeStructureSurfaceFitter.SurfaceAnchor rigid = anchor(72, 2);
NativeStructureSurfaceFitter.SurfaceAnchor junction = anchor(72, 1);
assertEquals(72, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(rigid), 2, 2, 40));
assertEquals(40, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(junction), 2, 2, 40));
}
@Test
public void elevatedRigidFootprintsBlendIntoAHorizontalRescueTaper() {
int originalY = 40;
int kernelEdge = NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(anchor(51, 2)), 5, 2, originalY);
int gapThirteen = NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(anchor(52, 2)), 5, 2, originalY);
int gapEighteen = NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(anchor(57, 2)), 5, 2, originalY);
int gapTwentyFour = NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(anchor(63, 2)), 5, 2, originalY);
assertEquals(originalY, kernelEdge);
assertTrue(gapThirteen >= kernelEdge);
assertTrue(gapEighteen > gapThirteen);
assertTrue(gapTwentyFour > gapEighteen);
assertTrue(gapTwentyFour < 63);
assertEquals(originalY, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(anchor(63, 2)), 16, 2, originalY));
assertEquals(originalY, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(anchor(63, 1)), 5, 2, originalY));
assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(anchor(64, 2)), 5, 2, 80));
}
@Test
public void projectedCenterAndRigidChildrenShareTerrainSupportAcrossTheAssembly() {
Structure structure = new DesertPyramidStructure(
new Structure.StructureSettings(
HolderSet.empty(), Map.of(),
GenerationStep.Decoration.SURFACE_STRUCTURES,
TerrainAdjustment.BEARD_BOX));
PoolElementStructurePiece center = rigidPiece(
new BoundingBox(0, 71, 0, 3, 80, 3), 1);
PoolElementStructurePiece child = rigidPiece(
new BoundingBox(10, 65, 0, 13, 76, 3), 7);
StructureStart start = new StructureStart(
structure, new ChunkPos(0, 0), 0,
new PiecesContainer(List.of(center, child)));
BoundingBox area = new BoundingBox(0, 60, 0, 15, 90, 15);
Map<BlockPos, BlockState> blocks = new HashMap<>();
for (int x = area.minX(); x <= area.maxX(); x++) {
for (int z = area.minZ(); z <= area.maxZ(); z++) {
put(blocks, x, 63, z, Blocks.DIRT.defaultBlockState());
put(blocks, x, 64, z, Blocks.GRASS_BLOCK.defaultBlockState());
}
} }
NativeStructureSurfaceFitter.prepareSurfaceStructures(
world(blocks), area,
List.of(new NativeStructureTerrainIntegrator.TerrainTarget(
"nova_structures:tavern_oak", start,
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))),
(x, z) -> 64);
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 1, 71, 1));
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 11, 71, 1));
assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 11, 70, 1));
} }
@Test @Test
public void surfaceAnchorIsFlushInsideAndUnchangedAtRadius() { public void surfaceAnchorIsFlushInsideAndUnchangedAtRadius() {
NativeStructureSurfaceFitter.SurfaceAnchor anchor = anchor(80, 2); NativeStructureSurfaceFitter.SurfaceAnchor anchor = anchor(72, 2);
assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(72, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(anchor), 2, 2, 64)); List.of(anchor), 2, 2, 64));
assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(anchor), 16, 2, 64)); List.of(anchor), 16, 2, 64));
@@ -97,54 +236,88 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
@Test @Test
public void surfaceAnchorRaisesAndLowersThroughTheTaper() { public void surfaceAnchorRaisesAndLowersThroughTheTaper() {
NativeStructureSurfaceFitter.SurfaceAnchor raised = anchor(80, 2); NativeStructureSurfaceFitter.SurfaceAnchor raised = anchor(68, 2);
NativeStructureSurfaceFitter.SurfaceAnchor lowered = anchor(64, 2); NativeStructureSurfaceFitter.SurfaceAnchor lowered = anchor(64, 2);
assertEquals(68, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(raised), 10, 2, 64)); List.of(raised), 10, 2, 64));
assertEquals(76, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(67, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(lowered), 10, 2, 80)); List.of(lowered), 10, 2, 68));
} }
@Test @Test
public void containingRigidFloorsHaveDeterministicPriority() { public void containingRigidFloorsHaveDeterministicPriority() {
NativeStructureSurfaceFitter.SurfaceAnchor rigid = anchor(70, 2); NativeStructureSurfaceFitter.SurfaceAnchor rigid = anchor(70, 2);
NativeStructureSurfaceFitter.SurfaceAnchor junction = anchor(90, 1); NativeStructureSurfaceFitter.SurfaceAnchor junction = anchor(74, 1);
assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(rigid, junction), 2, 2, 64)); List.of(rigid, junction), 2, 2, 64));
assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(junction, rigid), 2, 2, 64)); List.of(junction, rigid), 2, 2, 64));
NativeStructureSurfaceFitter.SurfaceAnchor weakTie = anchor(48, 1); NativeStructureSurfaceFitter.SurfaceAnchor weakTie = anchor(58, 1);
NativeStructureSurfaceFitter.SurfaceAnchor strongTie = anchor(80, 2); NativeStructureSurfaceFitter.SurfaceAnchor strongTie = anchor(70, 2);
assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(weakTie, strongTie), 2, 2, 64)); List.of(weakTie, strongTie), 2, 2, 64));
assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(strongTie, weakTie), 2, 2, 64)); List.of(strongTie, weakTie), 2, 2, 64));
} }
@Test
public void stackedRigidPiecesPreserveTheLowerAuthoredSurface() {
Structure structure = new DesertPyramidStructure(
new Structure.StructureSettings(
HolderSet.empty(), Map.of(),
GenerationStep.Decoration.SURFACE_STRUCTURES,
TerrainAdjustment.BEARD_BOX));
PoolElementStructurePiece lower = rigidPiece(
new BoundingBox(0, 62, 0, 4, 65, 4), 1);
PoolElementStructurePiece upper = rigidPiece(
new BoundingBox(0, 66, 0, 4, 78, 4), 1);
StructureStart start = new StructureStart(
structure, new ChunkPos(0, 0), 0,
new PiecesContainer(List.of(upper, lower)));
BoundingBox area = new BoundingBox(0, 58, 0, 4, 78, 4);
Map<BlockPos, BlockState> blocks = new HashMap<>();
for (int x = area.minX(); x <= area.maxX(); x++) {
for (int z = area.minZ(); z <= area.maxZ(); z++) {
put(blocks, x, 59, z, Blocks.DIRT.defaultBlockState());
put(blocks, x, 60, z, Blocks.GRASS_BLOCK.defaultBlockState());
}
}
NativeStructureSurfaceFitter.prepareSurfaceStructures(
world(blocks), area,
List.of(new NativeStructureTerrainIntegrator.TerrainTarget(
"nova_structures:tavern_oak", start,
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))),
(x, z) -> 60);
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 2, 62, 2));
assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 2, 65, 2));
}
@Test @Test
public void containingFootprintOverridesAnAdjacentPiecesFalloff() { public void containingFootprintOverridesAnAdjacentPiecesFalloff() {
NativeStructureSurfaceFitter.SurfaceAnchor local = NativeStructureSurfaceFitter.SurfaceAnchor local =
new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, 65, 2); new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, 66, 2);
NativeStructureSurfaceFitter.SurfaceAnchor adjacent = NativeStructureSurfaceFitter.SurfaceAnchor adjacent =
new NativeStructureSurfaceFitter.SurfaceAnchor(5, 9, 0, 4, 80, 2); new NativeStructureSurfaceFitter.SurfaceAnchor(5, 9, 0, 4, 70, 2);
assertEquals(77, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(65, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(adjacent), 4, 2, 64)); List.of(adjacent), 4, 2, 64));
assertEquals(65, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(66, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(local, adjacent), 4, 2, 64)); List.of(local, adjacent), 4, 2, 64));
assertEquals(65, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(66, NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(adjacent, local), 4, 2, 64)); List.of(adjacent, local), 4, 2, 64));
} }
@Test @Test
public void opposingFalloffsBlendWithoutAnAbruptMidpointSeam() { public void opposingFalloffsBlendWithoutAnAbruptMidpointSeam() {
NativeStructureSurfaceFitter.SurfaceAnchor high = NativeStructureSurfaceFitter.SurfaceAnchor high =
new NativeStructureSurfaceFitter.SurfaceAnchor(0, 0, 0, 0, 80, 2); new NativeStructureSurfaceFitter.SurfaceAnchor(0, 0, 0, 0, 70, 2);
NativeStructureSurfaceFitter.SurfaceAnchor low = NativeStructureSurfaceFitter.SurfaceAnchor low =
new NativeStructureSurfaceFitter.SurfaceAnchor(12, 12, 0, 0, 48, 2); new NativeStructureSurfaceFitter.SurfaceAnchor(12, 12, 0, 0, 58, 2);
int previous = NativeStructureSurfaceFitter.resolveSurfaceTarget( int previous = NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(high, low), 0, 0, 64); List.of(high, low), 0, 0, 64);
@@ -154,7 +327,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
int reversed = NativeStructureSurfaceFitter.resolveSurfaceTarget( int reversed = NativeStructureSurfaceFitter.resolveSurfaceTarget(
List.of(low, high), x, 0, 64); List.of(low, high), x, 0, 64);
assertEquals(forward, reversed); assertEquals(forward, reversed);
assertTrue(Math.abs(forward - previous) <= 4); assertTrue(Math.abs(forward - previous) <= 6);
previous = forward; previous = forward;
} }
assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget( assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget(
@@ -195,6 +368,44 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(raised, 0, 68, 0)); assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(raised, 0, 68, 0));
} }
@Test
public void rigidSurfaceBaseClosesAOneBlockSubsurfaceGap() {
Map<BlockPos, BlockState> blocks = new HashMap<>();
put(blocks, 0, 62, 0, Blocks.STONE.defaultBlockState());
put(blocks, 0, 64, 0, Blocks.GRASS_BLOCK.defaultBlockState());
NativeStructureSurfaceFitter.applySurfaceColumn(
world(blocks), new BlockPos.MutableBlockPos(),
0, 0, 64, 64, -64, 319, true);
assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, 63, 0));
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 0, 64, 0));
}
@Test
public void rigidSurfaceBaseReconstructsAMissingMeetLayer() {
Map<BlockPos, BlockState> blocks = new HashMap<>();
put(blocks, 0, 62, 0, Blocks.STONE.defaultBlockState());
NativeStructureSurfaceFitter.applySurfaceColumn(
world(blocks), new BlockPos.MutableBlockPos(),
0, 0, 64, 64, -64, 319, true);
assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, 63, 0));
assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, 64, 0));
}
@Test
public void rigidSurfaceBaseReconstructsAMissingMeetLayerAtTheWorldFloor() {
Map<BlockPos, BlockState> blocks = new HashMap<>();
NativeStructureSurfaceFitter.applySurfaceColumn(
world(blocks), new BlockPos.MutableBlockPos(),
0, 0, -64, -64, -64, 319, true);
assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, -64, 0));
}
@Test @Test
public void raisedSurfaceTerrainDoesNotSliceTreeBlocks() { public void raisedSurfaceTerrainDoesNotSliceTreeBlocks() {
Map<BlockPos, BlockState> blocks = new HashMap<>(); Map<BlockPos, BlockState> blocks = new HashMap<>();
@@ -213,6 +424,31 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
assertEquals(leaves, state(blocks, 0, 68, 0)); assertEquals(leaves, state(blocks, 0, 68, 0));
} }
@Test
public void clearedIntersectingTreeCellsBecomeRaisedTerrainSupport() {
ProtoChunk chunk = new ProtoChunk(
new ChunkPos(0, 0), UpgradeData.EMPTY,
LevelHeightAccessor.create(-64, 384), containerFactory(), null);
write(chunk, 0, 63, 0, Blocks.DIRT.defaultBlockState());
write(chunk, 0, 64, 0, Blocks.GRASS_BLOCK.defaultBlockState());
write(chunk, 0, 66, 0, Blocks.OAK_LOG.defaultBlockState());
write(chunk, 0, 68, 0, Blocks.OAK_LEAVES.defaultBlockState());
WorldGenLevel world = world(chunk);
BoundingBox area = new BoundingBox(0, 63, 0, 0, 68, 0);
NativeStructureVegetationClearer.clearIntersectingVegetation(
world, chunk, area, List.of(
new NativeStructureVegetationClearer.VegetationTarget(desertStart(), false)));
NativeStructureSurfaceFitter.applySurfaceColumn(
world, new BlockPos.MutableBlockPos(),
0, 0, 64, 68, -64, 319);
assertEquals(Blocks.DIRT.defaultBlockState(), chunk.getBlockState(new BlockPos(0, 65, 0)));
assertEquals(Blocks.DIRT.defaultBlockState(), chunk.getBlockState(new BlockPos(0, 66, 0)));
assertEquals(Blocks.DIRT.defaultBlockState(), chunk.getBlockState(new BlockPos(0, 67, 0)));
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), chunk.getBlockState(new BlockPos(0, 68, 0)));
}
@Test @Test
public void loweredFluidColumnsRemainFluidFilled() { public void loweredFluidColumnsRemainFluidFilled() {
Map<BlockPos, BlockState> blocks = new HashMap<>(); Map<BlockPos, BlockState> blocks = new HashMap<>();
@@ -437,6 +673,47 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
assertNotSame(first, other); assertNotSame(first, other);
} }
@Test
public void concurrentCarveRequestsShareOneFootprintBuild() throws Exception {
StructureStart start = desertStart();
int requestCount = 16;
ExecutorService executor = Executors.newFixedThreadPool(requestCount);
CountDownLatch ready = new CountDownLatch(requestCount);
CountDownLatch release = new CountDownLatch(1);
List<Future<StructureCarvingFootprint>> futures = new ArrayList<>();
try {
for (int request = 0; request < requestCount; request++) {
futures.add(executor.submit(() -> {
ready.countDown();
release.await();
return NativeStructureTerrainIntegrator.carveFootprint(
start, 11,
NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager);
}));
}
assertTrue(ready.await(5, TimeUnit.SECONDS));
release.countDown();
StructureCarvingFootprint expected = futures.getFirst().get();
for (Future<StructureCarvingFootprint> future : futures) {
assertSame(expected, future.get());
}
} finally {
executor.shutdownNow();
}
}
@Test
public void carveFootprintCacheStaysInsideItsCellBudget() {
for (int index = 0; index < 40; index++) {
NativeStructureTerrainIntegrator.carveFootprint(
desertStart(), 128,
NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager);
}
assertTrue(NativeStructureTerrainIntegrator.cachedCarveFootprintCells()
<= NativeStructureTerrainIntegrator.maximumCachedCarveFootprintCells());
}
@Test @Test
public void organicCarveNeverCutsBelowTheColumnSupportingFloor() { public void organicCarveNeverCutsBelowTheColumnSupportingFloor() {
StructureStart start = desertStart(); StructureStart start = desertStart();
@@ -594,43 +871,48 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
public void nativeTerrainEnvelopePersistsHorizontalReferenceCoverage() { public void nativeTerrainEnvelopePersistsHorizontalReferenceCoverage() {
StructureStart generated = desertStart(); StructureStart generated = desertStart();
BoundingBox content = generated.getBoundingBox(); BoundingBox content = generated.getBoundingBox();
IrisStructureTerrain terrain = new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
.setHorizontalPadding(24);
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
generated, generated,
generated.getStructure(), generated.getStructure(),
0, 0,
null, terrain);
new IrisStructureTerrain() BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds(
.setMode(IrisStructureTerrainMode.FORCE_CARVE) wrapped, wrapped.getStructure(), terrain);
.setHorizontalPadding(24));
assertEquals(content, NativeStructureReferenceEnvelope.contentBounds(wrapped)); assertEquals(content, NativeStructureReferenceEnvelope.contentBounds(wrapped));
assertEquals(content.minX() - 24, wrapped.getBoundingBox().minX()); assertEquals(content, wrapped.getBoundingBox());
assertEquals(content.minZ() - 24, wrapped.getBoundingBox().minZ()); assertEquals(content.minX() - 24, referenceBounds.minX());
assertEquals(content.maxX() + 24, wrapped.getBoundingBox().maxX()); assertEquals(content.minZ() - 24, referenceBounds.minZ());
assertEquals(content.maxZ() + 24, wrapped.getBoundingBox().maxZ()); assertEquals(content.maxX() + 24, referenceBounds.maxX());
assertEquals(2, wrapped.getPieces().stream() assertEquals(content.maxZ() + 24, referenceBounds.maxZ());
.filter(NativeStructureReferenceEnvelope::isMarker) assertEquals(generated.getPieces().size(), wrapped.getPieces().size());
.count());
} }
@Test @Test
public void nativeTerrainEnvelopeClipsToMinecraftReferenceCoverage() { public void nativeTerrainEnvelopeClipsOptionalCoverageWithoutDroppingContent() {
StructureStart generated = desertStart(); StructureStart generated = desertStart();
ChunkPos origin = generated.getChunkPos();
BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(generated);
IrisStructureTerrain terrain = new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
.setHorizontalPadding(124);
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( BoundingBox references = NativeStructureReferenceEnvelope.referenceBounds(
generated, generated, generated.getStructure(), terrain, "test:clipped_terrain");
generated.getStructure(), StructureStart published = NativeStructureReferenceEnvelope.wrapForPublication(
0, generated, generated.getStructure(), 0, terrain, "test:clipped_terrain");
null,
new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
.setHorizontalPadding(128));
assertEquals(-128, wrapped.getBoundingBox().minX()); assertEquals(Math.max(content.minX() - 124, (origin.x() - 8) << 4), references.minX());
assertEquals(-128, wrapped.getBoundingBox().minZ()); assertEquals(Math.min(content.maxX() + 124, ((origin.x() + 8) << 4) + 15), references.maxX());
assertEquals(143, wrapped.getBoundingBox().maxX()); assertEquals(Math.max(content.minZ() - 124, (origin.z() - 8) << 4), references.minZ());
assertEquals(143, wrapped.getBoundingBox().maxZ()); assertEquals(Math.min(content.maxZ() + 124, ((origin.z() + 8) << 4) + 15), references.maxZ());
assertTrue(references.isInside(content.getCenter()));
assertTrue(published.isValid());
assertEquals(1, generated.getPieces().size());
} }
@Test @Test
@@ -726,6 +1008,16 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
return new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, meetY, strength); return new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, meetY, strength);
} }
private static PoolElementStructurePiece rigidPiece(BoundingBox bounds, int groundLevelDelta) {
StructurePoolElement element = StructurePoolElement.single(
"minecraft:empty").apply(StructureTemplatePool.Projection.RIGID);
return new PoolElementStructurePiece(
null, element,
new BlockPos(bounds.minX(), bounds.minY(), bounds.minZ()),
groundLevelDelta, Rotation.NONE, bounds,
LiquidSettings.APPLY_WATERLOGGING);
}
private static NativeStructureTerrainIntegrator.OrganicCarve organicCarve( private static NativeStructureTerrainIntegrator.OrganicCarve organicCarve(
StructureStart start, int horizontalPadding) { StructureStart start, int horizontalPadding) {
return organicCarve(start, horizontalPadding, 0.85D); return organicCarve(start, horizontalPadding, 0.85D);
@@ -831,8 +1123,14 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
} }
private static StructureStart desertStart() { private static StructureStart desertStart() {
return desertStart(TerrainAdjustment.NONE);
}
private static StructureStart desertStart(TerrainAdjustment adjustment) {
Structure structure = new DesertPyramidStructure( Structure structure = new DesertPyramidStructure(
new Structure.StructureSettings(HolderSet.empty())); new Structure.StructureSettings(
HolderSet.empty(), Map.of(),
GenerationStep.Decoration.SURFACE_STRUCTURES, adjustment));
DesertPyramidPiece piece = new DesertPyramidPiece(RandomSource.create(7L), 0, 0); DesertPyramidPiece piece = new DesertPyramidPiece(RandomSource.create(7L), 0, 0);
return new StructureStart( return new StructureStart(
structure, new ChunkPos(0, 0), 0, new PiecesContainer(List.of(piece))); structure, new ChunkPos(0, 0), 0, new PiecesContainer(List.of(piece)));
@@ -854,6 +1152,9 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
if (methodName.equals("getSeed")) { if (methodName.equals("getSeed")) {
return TEST_SEED; return TEST_SEED;
} }
if (methodName.equals("getLevel")) {
return null;
}
if (methodName.equals("hashCode")) { if (methodName.equals("hashCode")) {
return System.identityHashCode(proxy); return System.identityHashCode(proxy);
} }
@@ -869,6 +1170,62 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
WorldGenLevel.class.getClassLoader(), new Class<?>[]{WorldGenLevel.class}, handler); WorldGenLevel.class.getClassLoader(), new Class<?>[]{WorldGenLevel.class}, handler);
} }
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("hashCode")) {
return System.identityHashCode(proxy);
}
if (methodName.equals("equals")) {
return proxy == arguments[0];
}
if (methodName.equals("toString")) {
return "surface-test-chunk-world";
}
throw new UnsupportedOperationException(method.toString());
};
return (WorldGenLevel) Proxy.newProxyInstance(
WorldGenLevel.class.getClassLoader(), new Class<?>[]{WorldGenLevel.class}, handler);
}
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 PalettedContainerFactory containerFactory() {
Strategy<BlockState> blockStates = Strategy.createForBlockStates(Block.BLOCK_STATE_REGISTRY);
BlockState air = Blocks.AIR.defaultBlockState();
IdMapper<Holder<Biome>> biomeIds = new IdMapper<>();
Holder<Biome> defaultBiome = Holder.direct((Biome) null);
biomeIds.add(defaultBiome);
Strategy<Holder<Biome>> biomes = Strategy.createForBiomes(biomeIds);
Codec<Holder<Biome>> 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));
}
private static void put(Map<BlockPos, BlockState> blocks, private static void put(Map<BlockPos, BlockState> blocks,
int x, int y, int z, BlockState state) { int x, int y, int z, BlockState state) {
blocks.put(new BlockPos(x, y, z), state); blocks.put(new BlockPos(x, y, z), state);
@@ -1,14 +1,33 @@
package art.arcane.iris.nativegen; package art.arcane.iris.nativegen;
import net.minecraft.SharedConstants;
import net.minecraft.core.HolderSet;
import net.minecraft.server.Bootstrap;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.levelgen.GenerationStep; import net.minecraft.world.level.levelgen.GenerationStep;
import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece;
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure;
import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class NativeStructurePostProcessorVegetationTest { public class NativeStructurePostProcessorVegetationTest {
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test @Test
public void surfaceStructuresClearTreeColumnsAutomatically() { public void surfaceStructuresClearTreeColumnsAutomatically() {
assertTrue(NativeStructureVegetationClearer.shouldClearVegetationColumn(100, 100, false)); assertTrue(NativeStructureVegetationClearer.shouldClearVegetationColumn(100, 100, false));
@@ -26,6 +45,22 @@ public class NativeStructurePostProcessorVegetationTest {
assertTrue(NativeStructureVegetationClearer.shouldClearVegetationColumn(20, 100, true)); assertTrue(NativeStructureVegetationClearer.shouldClearVegetationColumn(20, 100, true));
} }
@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))));
assertTrue(NativeStructureVegetationClearer.shouldProcessTarget(
new NativeStructureVegetationClearer.VegetationTarget(start, false)));
assertFalse(NativeStructureVegetationClearer.shouldProcessTarget(
new NativeStructureVegetationClearer.VegetationTarget(
StructureStart.INVALID_START, true)));
}
@Test @Test
public void surfaceStructuresPreserveVegetationUnlessConfigured() { public void surfaceStructuresPreserveVegetationUnlessConfigured() {
assertFalse(NativeStructureVegetationClearer.shouldClearEntireVegetationFootprint( assertFalse(NativeStructureVegetationClearer.shouldClearEntireVegetationFootprint(
+1
View File
@@ -58,6 +58,7 @@ tasks.named('jar', Jar).configure {
tasks.named('test').configure { tasks.named('test').configure {
systemProperty('iris.commandFindSource', file('src/main/java/art/arcane/iris/core/commands/CommandFind.java').absolutePath) systemProperty('iris.commandFindSource', file('src/main/java/art/arcane/iris/core/commands/CommandFind.java').absolutePath)
systemProperty('iris.commandStructureSource', file('src/main/java/art/arcane/iris/core/commands/CommandStructure.java').absolutePath) systemProperty('iris.commandStructureSource', file('src/main/java/art/arcane/iris/core/commands/CommandStructure.java').absolutePath)
systemProperty('iris.commandIrisSource', file('src/main/java/art/arcane/iris/core/commands/CommandIris.java').absolutePath)
systemProperty('iris.bukkitChunkGeneratorSource', rootProject.file('core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java').absolutePath) systemProperty('iris.bukkitChunkGeneratorSource', rootProject.file('core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java').absolutePath)
systemProperty('iris.pregeneratorJobSource', rootProject.file('core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java').absolutePath) systemProperty('iris.pregeneratorJobSource', rootProject.file('core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java').absolutePath)
systemProperty('iris.bukkitEnginePlatformHooksSource', file('src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java').absolutePath) systemProperty('iris.bukkitEnginePlatformHooksSource', file('src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java').absolutePath)
@@ -36,6 +36,7 @@ import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.lifecycle.PaperLibBootstrap; import art.arcane.iris.core.lifecycle.PaperLibBootstrap;
import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.runtime.BukkitEnginePlatformHooks; import art.arcane.iris.core.runtime.BukkitEnginePlatformHooks;
import art.arcane.iris.core.runtime.WorldDeletionQueue;
import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.core.runtime.WorldRuntimeControlService;
import art.arcane.iris.api.terrain.IrisTerrainService; import art.arcane.iris.api.terrain.IrisTerrainService;
import art.arcane.iris.core.link.IrisPapiInstaller; import art.arcane.iris.core.link.IrisPapiInstaller;
@@ -571,7 +572,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisServices.register(EnginePlatformHooks.class, new BukkitEnginePlatformHooks()); IrisServices.register(EnginePlatformHooks.class, new BukkitEnginePlatformHooks());
IrisServices.register(EngineWorldManagerProvider.class, IrisServices.register(EngineWorldManagerProvider.class,
(EngineWorldManagerProvider) IrisWorldManager::new); (EngineWorldManagerProvider) IrisWorldManager::new);
IrisServices.register(art.arcane.iris.core.runtime.WorldDeletionQueue.class, (art.arcane.iris.core.runtime.WorldDeletionQueue) pendingWorldDeletes::queueWorldDeletionOnStartup); IrisServices.register(WorldDeletionQueue.class, pendingWorldDeletes);
SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("settings.json")); SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("settings.json"));
settingsHotloadWatch = watch; settingsHotloadWatch = watch;
configHotloadEngine = new ConfigHotloadEngine( configHotloadEngine = new ConfigHotloadEngine(
@@ -19,77 +19,381 @@
package art.arcane.iris.core; package art.arcane.iris.core;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.platform.bukkit.BukkitEnvironment; import art.arcane.iris.platform.bukkit.BukkitEnvironment;
import art.arcane.iris.util.common.format.C; import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.scheduling.J; import art.arcane.iris.util.common.misc.ServerProperties;
import art.arcane.volmlib.util.bukkit.WorldIdentity; import art.arcane.volmlib.util.bukkit.WorldIdentity;
import art.arcane.volmlib.util.collection.KList;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.WorldCreator; import org.bukkit.WorldCreator;
import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.ChunkGenerator;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate; import java.util.function.Predicate;
import java.util.stream.Stream;
/**
* Loads Iris worlds that are staged in bukkit.yml but not yet present on the server.
*/
public final class BukkitWorldReconciler { public final class BukkitWorldReconciler {
private final Iris plugin; private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L;
private final Backend backend;
private final LifecycleOperationCoordinator coordinator;
public BukkitWorldReconciler(Iris plugin) { public BukkitWorldReconciler(Iris plugin) {
this.plugin = plugin; this(new BukkitBackend(plugin), LifecycleOperationCoordinator.get());
} }
public void checkForBukkitWorlds(Predicate<String> filter) { BukkitWorldReconciler(Backend backend, LifecycleOperationCoordinator coordinator) {
this.backend = Objects.requireNonNull(backend, "backend");
this.coordinator = Objects.requireNonNull(coordinator, "coordinator");
}
public CompletableFuture<LoadResult> loadWorld(
File configurationFile,
String worldName
) {
NamespacedKey worldKey;
try { try {
KList<String> deferredStartupWorlds = new KList<>(); worldKey = IrisWorldStorage.managedKeyFromName(worldName);
IrisWorlds.readBukkitWorlds().forEach((s, generator) -> { } catch (Throwable failure) {
try { return CompletableFuture.completedFuture(LoadResult.validationFailure(worldName, failure));
NamespacedKey worldKey = IrisWorldStorage.keyFromName(s);
if (WorldIdentity.resolve(worldKey).isPresent() || !filter.test(s)) return;
Iris.info("Loading World: %s | Generator: %s", s, generator);
ChunkGenerator gen = plugin.getDefaultWorldGenerator(s, generator);
IrisDimension dim = IrisWorldGeneratorResolver.loadDimension(s, generator);
assert dim != null && gen != null;
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + s + "' using Iris:" + generator + "...");
WorldCreator c = WorldCreatorCompat.ofKey(worldKey)
.generator(gen)
.environment(BukkitEnvironment.from(dim.getEnvironment()));
Long stagedSeed = IrisWorlds.readBukkitWorldSeed(s);
if (stagedSeed != null) {
c.seed(stagedSeed);
}
INMS.get().createWorld(c);
Iris.info(C.LIGHT_PURPLE + "Loaded " + s + "!");
} catch (Throwable e) {
if (containsCreateWorldUnsupportedOperation(e)) {
if (J.isFolia()) {
if (!deferredStartupWorlds.contains(s)) {
deferredStartupWorlds.add(s);
}
return;
}
Iris.error("Failed to load world " + s + "!");
Iris.error("This server denied Bukkit.createWorld for \"" + s + "\" at the current startup phase.");
Iris.error("Ensure Iris is loaded at STARTUP and restart after staging worlds in bukkit.yml.");
Iris.reportError("Failed to load staged startup world \"" + s + "\".", e);
return;
}
Iris.reportError("Failed to load startup world \"" + s + "\".", e);
}
});
if (!deferredStartupWorlds.isEmpty()) {
Iris.warn("Staged Iris worlds could not load on Folia: %s", String.join(", ", deferredStartupWorlds));
Iris.warn("Bukkit.createWorld is unsupported on this server and the Iris runtime world backend is unavailable (%s).", WorldLifecycleService.get().capabilities().paperLikeResolution());
}
} catch (Throwable e) {
Iris.reportError("Failed while loading startup Iris worlds.", e);
} }
LifecycleOperationCoordinator.Lease lease;
try {
lease = acquireWorldLoad(worldKey);
} catch (LifecycleOperationCoordinator.BusyException failure) {
return CompletableFuture.completedFuture(LoadResult.busy(worldKey, failure));
}
DimensionResolution dimensionResolution;
Long configuredSeed;
try {
dimensionResolution = backend.resolveDimension(worldKey);
configuredSeed = dimensionResolution.succeeded()
? backend.configuredSeed(IrisWorldStorage.logicalName(worldKey))
: null;
} catch (Throwable failure) {
dimensionResolution = DimensionResolution.failed(failure);
configuredSeed = null;
}
if (!dimensionResolution.succeeded()) {
lease.close();
return CompletableFuture.completedFuture(LoadResult.dimensionFailure(
worldKey,
dimensionResolution.failure()));
}
return loadWithLease(
configurationFile,
worldKey,
dimensionResolution.dimension(),
configuredSeed,
lease);
}
public CompletableFuture<BatchResult> checkForBukkitWorlds(Predicate<String> filter) {
Predicate<String> requiredFilter = Objects.requireNonNull(filter, "filter");
Map<String, String> configuredWorlds;
try {
configuredWorlds = backend.configuredWorlds();
} catch (Throwable failure) {
Iris.reportError("Failed while reading staged Bukkit worlds.", failure);
return CompletableFuture.completedFuture(new BatchResult(List.of(), failure));
}
CompletableFuture<List<LoadResult>> chain = CompletableFuture.completedFuture(new ArrayList<>());
for (Map.Entry<String, String> entry : configuredWorlds.entrySet()) {
String worldName = entry.getKey();
boolean selected;
try {
selected = requiredFilter.test(worldName);
} catch (Throwable failure) {
Iris.reportError("Failed while filtering staged Bukkit world \"" + worldName + "\".", failure);
return CompletableFuture.completedFuture(new BatchResult(List.of(), failure));
}
if (!selected) {
continue;
}
NamespacedKey worldKey;
try {
worldKey = IrisWorldStorage.keyFromName(worldName);
} catch (Throwable failure) {
chain = chain.thenApply(results -> {
results.add(LoadResult.validationFailure(worldName, failure));
return results;
});
continue;
}
String dimension = entry.getValue();
Long seed;
try {
seed = backend.configuredSeed(worldName);
} catch (Throwable failure) {
chain = chain.thenApply(results -> {
results.add(LoadResult.configurationFailure(worldKey, failure));
return results;
});
continue;
}
chain = chain.thenCompose(results -> loadConfiguredWorld(
ServerProperties.BUKKIT_YML,
worldKey,
dimension,
seed)
.thenApply(result -> {
results.add(result);
return results;
}));
}
return chain.thenApply(results -> {
BatchResult batchResult = new BatchResult(results, null);
reportBatch(batchResult);
return batchResult;
});
}
private CompletableFuture<LoadResult> loadConfiguredWorld(
File configurationFile,
NamespacedKey worldKey,
String dimension,
Long seed
) {
LifecycleOperationCoordinator.Lease lease;
try {
lease = acquireWorldLoad(worldKey);
} catch (LifecycleOperationCoordinator.BusyException failure) {
return CompletableFuture.completedFuture(LoadResult.busy(worldKey, failure));
}
return loadWithLease(configurationFile, worldKey, dimension, seed, lease);
}
private LifecycleOperationCoordinator.Lease acquireWorldLoad(NamespacedKey worldKey) {
return coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_LOAD,
worldKey.toString());
}
private CompletableFuture<LoadResult> loadWithLease(
File configurationFile,
NamespacedKey worldKey,
String dimension,
Long seed,
LifecycleOperationCoordinator.Lease lease
) {
BukkitWorldConfiguration.Registration registration;
String worldName = IrisWorldStorage.logicalName(worldKey);
try {
registration = BukkitWorldConfiguration.register(
configurationFile,
worldName,
dimension,
seed);
} catch (Throwable failure) {
lease.close();
return CompletableFuture.completedFuture(LoadResult.configurationFailure(worldKey, failure));
}
CompletableFuture<ReconciliationResult> reconciliation;
try {
reconciliation = reconcile(worldKey, dimension, seed);
} catch (Throwable failure) {
reconciliation = CompletableFuture.completedFuture(ReconciliationResult.createFailure(worldKey, failure));
}
return reconciliation.handle((result, failure) -> {
ReconciliationResult settled = failure == null
? result
: ReconciliationResult.createFailure(worldKey, unwrap(failure));
if (settled == null) {
settled = ReconciliationResult.createFailure(
worldKey,
new IllegalStateException("World reconciliation completed without a result."));
}
if (settled.succeeded()
|| settled.status() == ReconciliationStatus.RESTART_REQUIRED
|| registration != BukkitWorldConfiguration.Registration.CREATED) {
return new LoadResult(settled, registration, false, true, null);
}
try {
boolean rolledBack = BukkitWorldConfiguration.removeIfMatching(
configurationFile,
worldName,
dimension,
seed);
return new LoadResult(settled, registration, true, rolledBack, null);
} catch (Throwable rollbackFailure) {
return new LoadResult(settled, registration, true, false, rollbackFailure);
}
})
.whenComplete((result, failure) -> lease.close());
}
private CompletableFuture<ReconciliationResult> reconcile(
NamespacedKey worldKey,
String dimension,
Long seed
) {
Optional<World> loaded = backend.loadedWorld(worldKey);
if (loaded.isPresent()) {
return CompletableFuture.completedFuture(verifyLoadedWorld(worldKey, loaded.get(), true));
}
CompletableFuture<World> created;
try {
created = Objects.requireNonNull(
backend.createWorld(worldKey, dimension, seed),
"World backend returned no creation future.");
} catch (Throwable failure) {
return CompletableFuture.completedFuture(classifyCreationFailure(worldKey, failure));
}
CompletableFuture<World> guardedCreation = guardCreateCompletion(
created,
worldKey,
TimeUnit.SECONDS.toMillis(WORLD_CREATE_TIMEOUT_SECONDS),
() -> ServerConfigurator.restart("World load timed out for \"" + worldKey + "\"."));
return guardedCreation.handle((createdWorld, failure) -> {
if (failure != null) {
return classifyCreationFailure(worldKey, unwrap(failure));
}
if (createdWorld == null) {
return ReconciliationResult.notLoaded(worldKey);
}
NamespacedKey createdKey;
try {
createdKey = WorldIdentity.key(createdWorld);
} catch (Throwable identityFailure) {
return ReconciliationResult.createFailure(worldKey, identityFailure);
}
if (!worldKey.equals(createdKey)) {
return ReconciliationResult.identityMismatch(worldKey, createdWorld, createdKey);
}
Optional<World> resolved = backend.loadedWorld(worldKey);
if (resolved.isEmpty()) {
return ReconciliationResult.notLoaded(worldKey);
}
return verifyLoadedWorld(worldKey, resolved.get(), false);
});
}
private ReconciliationResult verifyLoadedWorld(NamespacedKey worldKey, World loadedWorld, boolean alreadyLoaded) {
NamespacedKey loadedKey;
try {
loadedKey = WorldIdentity.key(loadedWorld);
} catch (Throwable identityFailure) {
return ReconciliationResult.createFailure(worldKey, identityFailure);
}
if (!worldKey.equals(loadedKey)) {
return ReconciliationResult.identityMismatch(worldKey, loadedWorld, loadedKey);
}
if (!backend.isIrisWorld(loadedWorld)) {
return ReconciliationResult.identityConflict(worldKey, loadedWorld);
}
return alreadyLoaded
? ReconciliationResult.alreadyLoaded(worldKey, loadedWorld)
: ReconciliationResult.loaded(worldKey, loadedWorld);
}
private static ReconciliationResult classifyCreationFailure(NamespacedKey worldKey, Throwable failure) {
Throwable cause = unwrap(failure);
if (cause instanceof TimeoutException || containsCreateWorldUnsupportedOperation(cause)) {
return ReconciliationResult.restartRequired(worldKey, cause);
}
return ReconciliationResult.createFailure(worldKey, cause);
}
static CompletableFuture<World> guardCreateCompletion(
CompletableFuture<World> source,
NamespacedKey worldKey,
long timeoutMillis,
Runnable timeoutAction
) {
Objects.requireNonNull(source, "source");
Objects.requireNonNull(worldKey, "worldKey");
Objects.requireNonNull(timeoutAction, "timeoutAction");
if (timeoutMillis < 1L) {
throw new IllegalArgumentException("timeoutMillis must be positive");
}
CompletableFuture<World> guarded = new CompletableFuture<>();
AtomicBoolean settled = new AtomicBoolean(false);
source.whenComplete((world, throwable) -> {
if (!settled.compareAndSet(false, true)) {
return;
}
if (throwable == null) {
guarded.complete(world);
} else {
guarded.completeExceptionally(unwrap(throwable));
}
});
CompletableFuture.delayedExecutor(timeoutMillis, TimeUnit.MILLISECONDS).execute(() -> {
if (!settled.compareAndSet(false, true)) {
return;
}
TimeoutException timeout = new TimeoutException(
"World load did not settle within " + timeoutMillis + " milliseconds for \""
+ worldKey + "\".");
try {
timeoutAction.run();
} catch (Throwable failure) {
timeout.addSuppressed(failure);
}
guarded.completeExceptionally(timeout);
});
return guarded;
}
private static void reportBatch(BatchResult batchResult) {
for (LoadResult result : batchResult.results()) {
if (result.succeeded()) {
Iris.info(C.LIGHT_PURPLE + result.message());
continue;
}
if (result.status() == ReconciliationStatus.BUSY) {
Iris.warn(result.message());
continue;
}
Iris.error(result.message());
Throwable failure = result.failure();
if (failure != null) {
Iris.reportError("Failed to reconcile staged world \"" + result.worldKey() + "\".", failure);
}
}
}
private static Throwable unwrap(Throwable failure) {
Throwable current = failure;
while (current instanceof CompletionException && current.getCause() != null) {
current = current.getCause();
}
return current;
} }
private static boolean containsCreateWorldUnsupportedOperation(Throwable throwable) { private static boolean containsCreateWorldUnsupportedOperation(Throwable throwable) {
@@ -107,4 +411,321 @@ public final class BukkitWorldReconciler {
} }
return false; return false;
} }
interface Backend {
Map<String, String> configuredWorlds();
Long configuredSeed(String worldName);
Optional<World> loadedWorld(NamespacedKey worldKey);
CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed);
boolean isIrisWorld(World world);
DimensionResolution resolveDimension(NamespacedKey worldKey);
}
public enum ReconciliationStatus {
LOADED,
ALREADY_LOADED,
BUSY,
INVALID_WORLD,
DIMENSION_UNRESOLVED,
CONFIGURATION_FAILED,
CREATE_FAILED,
RESTART_REQUIRED,
IDENTITY_MISMATCH,
IDENTITY_CONFLICT,
NOT_LOADED
}
public record ReconciliationResult(
ReconciliationStatus status,
NamespacedKey worldKey,
World world,
Throwable failure,
String message
) {
public ReconciliationResult {
Objects.requireNonNull(status, "status");
Objects.requireNonNull(message, "message");
}
public boolean succeeded() {
return status == ReconciliationStatus.LOADED || status == ReconciliationStatus.ALREADY_LOADED;
}
private static ReconciliationResult loaded(NamespacedKey worldKey, World world) {
return new ReconciliationResult(
ReconciliationStatus.LOADED,
worldKey,
world,
null,
"Loaded Iris world \"" + worldKey + "\".");
}
private static ReconciliationResult alreadyLoaded(NamespacedKey worldKey, World world) {
return new ReconciliationResult(
ReconciliationStatus.ALREADY_LOADED,
worldKey,
world,
null,
"Iris world \"" + worldKey + "\" is already loaded.");
}
private static ReconciliationResult createFailure(NamespacedKey worldKey, Throwable failure) {
return new ReconciliationResult(
ReconciliationStatus.CREATE_FAILED,
worldKey,
null,
failure,
"Failed to create Iris world \"" + worldKey + "\": " + failure.getMessage());
}
private static ReconciliationResult restartRequired(NamespacedKey worldKey, Throwable failure) {
return new ReconciliationResult(
ReconciliationStatus.RESTART_REQUIRED,
worldKey,
null,
failure,
"The server cannot load exact Iris world \"" + worldKey + "\" at this runtime phase.");
}
private static ReconciliationResult identityMismatch(
NamespacedKey worldKey,
World world,
NamespacedKey actualKey
) {
return new ReconciliationResult(
ReconciliationStatus.IDENTITY_MISMATCH,
worldKey,
world,
null,
"World creation returned \"" + actualKey + "\" instead of \"" + worldKey + "\".");
}
private static ReconciliationResult identityConflict(NamespacedKey worldKey, World world) {
return new ReconciliationResult(
ReconciliationStatus.IDENTITY_CONFLICT,
worldKey,
world,
null,
"World \"" + worldKey + "\" is loaded, but it is not an Iris world.");
}
private static ReconciliationResult notLoaded(NamespacedKey worldKey) {
return new ReconciliationResult(
ReconciliationStatus.NOT_LOADED,
worldKey,
null,
null,
"World creation completed without loading exact Iris world \"" + worldKey + "\".");
}
}
public record LoadResult(
ReconciliationResult reconciliation,
BukkitWorldConfiguration.Registration registration,
boolean rollbackAttempted,
boolean rollbackSucceeded,
Throwable rollbackFailure
) {
public LoadResult {
Objects.requireNonNull(reconciliation, "reconciliation");
}
public boolean succeeded() {
return reconciliation.succeeded() && rollbackFailure == null;
}
public ReconciliationStatus status() {
return reconciliation.status();
}
public NamespacedKey worldKey() {
return reconciliation.worldKey();
}
public World world() {
return reconciliation.world();
}
public Throwable failure() {
return rollbackFailure == null ? reconciliation.failure() : rollbackFailure;
}
public String message() {
if (rollbackFailure != null) {
return reconciliation.message() + " Failed to roll back bukkit.yml: " + rollbackFailure.getMessage();
}
if (rollbackAttempted && rollbackSucceeded) {
return reconciliation.message() + " The new bukkit.yml entry was rolled back.";
}
if (rollbackAttempted && !rollbackSucceeded) {
return reconciliation.message() + " The new bukkit.yml entry was no longer an exact match and was not modified.";
}
return reconciliation.message();
}
private static LoadResult validationFailure(String worldName, Throwable failure) {
ReconciliationResult reconciliation = new ReconciliationResult(
ReconciliationStatus.INVALID_WORLD,
null,
null,
failure,
"Invalid Iris world identifier \"" + worldName + "\": " + failure.getMessage());
return new LoadResult(reconciliation, null, false, true, null);
}
private static LoadResult busy(NamespacedKey worldKey, LifecycleOperationCoordinator.BusyException failure) {
ReconciliationResult reconciliation = new ReconciliationResult(
ReconciliationStatus.BUSY,
worldKey,
null,
failure,
failure.getMessage());
return new LoadResult(reconciliation, null, false, true, null);
}
private static LoadResult configurationFailure(NamespacedKey worldKey, Throwable failure) {
ReconciliationResult reconciliation = new ReconciliationResult(
ReconciliationStatus.CONFIGURATION_FAILED,
worldKey,
null,
failure,
"Failed to register Iris world \"" + worldKey + "\" in bukkit.yml: " + failure.getMessage());
return new LoadResult(reconciliation, null, false, true, null);
}
private static LoadResult dimensionFailure(NamespacedKey worldKey, Throwable failure) {
ReconciliationResult reconciliation = new ReconciliationResult(
ReconciliationStatus.DIMENSION_UNRESOLVED,
worldKey,
null,
failure,
"Could not determine one Iris dimension for world \"" + worldKey + "\": " + failure.getMessage());
return new LoadResult(reconciliation, null, false, true, null);
}
}
record DimensionResolution(String dimension, Throwable failure) {
DimensionResolution {
if ((dimension == null) == (failure == null)) {
throw new IllegalArgumentException("Dimension resolution must contain exactly one outcome.");
}
}
static DimensionResolution resolved(String dimension) {
return new DimensionResolution(Objects.requireNonNull(dimension, "dimension"), null);
}
static DimensionResolution failed(Throwable failure) {
return new DimensionResolution(null, Objects.requireNonNull(failure, "failure"));
}
boolean succeeded() {
return dimension != null;
}
}
public record BatchResult(List<LoadResult> results, Throwable failure) {
public BatchResult {
results = List.copyOf(Objects.requireNonNull(results, "results"));
}
public boolean succeeded() {
return failure == null && results.stream().allMatch(LoadResult::succeeded);
}
}
private static final class BukkitBackend implements Backend {
private final Iris plugin;
private BukkitBackend(Iris plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin");
}
@Override
public Map<String, String> configuredWorlds() {
return new LinkedHashMap<>(IrisWorlds.readBukkitWorlds());
}
@Override
public Long configuredSeed(String worldName) {
return IrisWorlds.readBukkitWorldSeed(worldName);
}
@Override
public Optional<World> loadedWorld(NamespacedKey worldKey) {
return WorldIdentity.resolve(worldKey);
}
@Override
public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
try {
String worldName = IrisWorldStorage.logicalName(worldKey);
Iris.info("Loading World: %s | Generator: %s", worldName, dimension);
ChunkGenerator generator = plugin.getDefaultWorldGenerator(worldName, dimension);
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension);
if (generator == null || irisDimension == null) {
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
}
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + worldName + " using Iris:" + dimension + "...");
WorldCreator creator = WorldCreatorCompat.ofKey(worldKey)
.generator(generator)
.environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
if (seed != null) {
creator.seed(seed);
}
return INMS.get().createWorldAsync(creator);
} catch (Throwable failure) {
return CompletableFuture.failedFuture(failure);
}
}
@Override
public boolean isIrisWorld(World world) {
return IrisToolbelt.isIrisWorld(world);
}
@Override
public DimensionResolution resolveDimension(NamespacedKey worldKey) {
File dimensionsDirectory = new File(IrisWorldStorage.packRoot(worldKey), "dimensions");
if (!dimensionsDirectory.isDirectory()) {
return DimensionResolution.failed(new IllegalStateException("The world has no Iris dimensions directory."));
}
List<String> dimensions = new ArrayList<>();
Path dimensionsRoot = dimensionsDirectory.toPath().toAbsolutePath().normalize();
try (Stream<Path> paths = Files.walk(dimensionsRoot)) {
paths.filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS))
.filter(path -> path.getFileName().toString().endsWith(".json"))
.forEach(path -> {
String relative = dimensionsRoot.relativize(path).toString().replace(File.separatorChar, '/');
dimensions.add(relative.substring(0, relative.length() - 5));
});
} catch (IOException failure) {
return DimensionResolution.failed(new IllegalStateException(
"The Iris dimensions directory could not be read.",
failure));
}
Collections.sort(dimensions);
String registeredDimension = IrisWorlds.get().getWorlds().get(worldKey.toString());
if (registeredDimension != null && dimensions.contains(registeredDimension)) {
return DimensionResolution.resolved(registeredDimension);
}
if (dimensions.size() == 1) {
return DimensionResolution.resolved(dimensions.getFirst());
}
if (dimensions.isEmpty()) {
return DimensionResolution.failed(new IllegalStateException("No dimension definitions were found."));
}
return DimensionResolution.failed(new IllegalStateException(
"Multiple dimension definitions were found without an exact registered dimension: "
+ String.join(", ", dimensions)));
}
}
} }
@@ -23,6 +23,7 @@ import art.arcane.iris.core.lifecycle.WorldLifecycleStaging;
import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.BrokenPackException; import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator; import art.arcane.iris.core.pack.PackValidator;
@@ -33,7 +34,6 @@ import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.engine.platform.BukkitChunkGenerator;
import art.arcane.iris.util.common.plugin.VolmitPlugin; import art.arcane.iris.util.common.plugin.VolmitPlugin;
import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.volmlib.util.io.IO;
import lombok.NonNull; import lombok.NonNull;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
@@ -42,6 +42,7 @@ import org.bukkit.generator.ChunkGenerator;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.io.File; import java.io.File;
import java.util.List;
import java.util.function.Supplier; import java.util.function.Supplier;
/** /**
@@ -57,15 +58,12 @@ public final class IrisWorldGeneratorResolver {
public void validateAllPacks() { public void validateAllPacks() {
File packsRoot = plugin.getDataFolder("packs"); File packsRoot = plugin.getDataFolder("packs");
File[] packDirs = packsRoot.listFiles(File::isDirectory); List<File> packDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
if (packDirs == null || packDirs.length == 0) { PackValidationRegistry.clear();
if (packDirs.isEmpty()) {
return; return;
} }
PackValidationRegistry.clear();
for (File packDir : packDirs) { for (File packDir : packDirs) {
if (packDir.getName().contains(".importing-")) {
continue;
}
try { try {
PackValidationResult result = PackValidator.validate(packDir); PackValidationResult result = PackValidator.validate(packDir);
PackValidationRegistry.publish(result); PackValidationRegistry.publish(result);
@@ -167,16 +165,16 @@ public final class IrisWorldGeneratorResolver {
Iris.debug("Generator Config: " + w.toString()); Iris.debug("Generator Config: " + w.toString());
File ff = new File(w.worldFolder(), "iris/pack"); File ff = new File(w.worldFolder(), "iris/pack");
File[] files = ff.listFiles(); IrisDimension installedDimension = ff.isDirectory()
if (files == null || files.length == 0) ? IrisData.get(ff).getDimensionLoader().load(dim.getLoadKey(), false)
IO.delete(ff); : null;
if (installedDimension == null) {
if (!ff.exists()) { dim = Iris.service(StudioSVC.class).replaceIntoWorld(Iris.getSender(), dim, w.worldFolder());
ff.mkdirs();
dim = Iris.service(StudioSVC.class).installIntoWorld(Iris.getSender(), dim, w.worldFolder());
if (dim == null) { if (dim == null) {
throw new IllegalStateException("Failed to install dimension pack for " + id); throw new IllegalStateException("Failed to install dimension pack for " + id);
} }
} else {
dim = installedDimension;
} }
return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey()); return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey());
@@ -19,14 +19,11 @@
package art.arcane.iris.core; package art.arcane.iris.core;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.core.runtime.WorldDeletionQueue;
import art.arcane.iris.core.runtime.TransientWorldCleanupSupport; import art.arcane.iris.core.runtime.TransientWorldCleanupSupport;
import art.arcane.iris.core.tools.IrisCreator; import art.arcane.iris.core.tools.IrisCreator;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.util.common.misc.ServerProperties;
import art.arcane.iris.util.common.plugin.VolmitPlugin; import art.arcane.iris.util.common.plugin.VolmitPlugin;
import art.arcane.volmlib.util.bukkit.WorldIdentity; import art.arcane.volmlib.util.bukkit.WorldIdentity;
import art.arcane.volmlib.util.io.IO;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.World; import org.bukkit.World;
@@ -34,21 +31,38 @@ import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.File; import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException; import java.io.IOException;
import java.io.PrintWriter; import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
/** public final class PendingWorldDeleteQueue implements WorldDeletionQueue {
* Persistent queue of world folders that must be deleted on the next startup, plus the startup
* drain that actually removes them.
*/
public final class PendingWorldDeleteQueue {
private static final String PENDING_WORLD_DELETE_FILE = "pending-world-deletes.txt"; private static final String PENDING_WORLD_DELETE_FILE = "pending-world-deletes.txt";
private static final String EXACT_PREFIX = "exact:";
private static final Pattern SAFE_LOGICAL_NAME = Pattern.compile("^[a-z0-9_-]+$");
private static final Pattern QUARANTINE_NAME = Pattern.compile("^\\.iris-delete-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
private static final Set<String> VANILLA_DIMENSION_ALIASES = Set.of("overworld", "the_nether", "the_end");
private final VolmitPlugin plugin; private final VolmitPlugin plugin;
@@ -56,173 +70,413 @@ public final class PendingWorldDeleteQueue {
this.plugin = plugin; this.plugin = plugin;
} }
public synchronized int queueWorldDeletionOnStartup(Collection<String> worldNames) throws IOException { @Override
public synchronized int queueExactForStartupDeletion(Collection<String> worldNames) throws IOException {
return queueWorldDeletionOnStartup(worldNames, true);
}
@Override
public synchronized int queueFamilyForStartupDeletion(Collection<String> worldNames) throws IOException {
return queueWorldDeletionOnStartup(worldNames, false);
}
private int queueWorldDeletionOnStartup(Collection<String> worldNames, boolean exact) throws IOException {
if (worldNames == null || worldNames.isEmpty()) { if (worldNames == null || worldNames.isEmpty()) {
return 0; return 0;
} }
LinkedHashMap<String, String> queue = loadPendingWorldDeleteMap(); File levelRoot = IrisWorldStorage.levelRoot();
int before = queue.size(); ArrayList<String> normalizedNames = new ArrayList<>(worldNames.size());
for (String worldName : worldNames) { for (String worldName : worldNames) {
String normalized = normalizeWorldName(worldName); String normalized = normalizeQueueEntry(worldName, levelRoot.getName());
if (normalized == null) { if (normalized == null) {
continue; throw new IllegalArgumentException("Unsafe Iris world deletion target: " + worldName);
} }
queue.putIfAbsent(normalized.toLowerCase(Locale.ROOT), normalized); normalizedNames.add(exact && !QUARANTINE_NAME.matcher(normalized).matches()
? EXACT_PREFIX + normalized
: normalized);
}
File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE);
LinkedHashMap<String, String> queue = loadPendingWorldDeleteMap(queueFile, levelRoot.getName());
int before = queue.size();
for (String normalized : normalizedNames) {
mergeQueueEntry(queue, normalized);
} }
if (queue.size() != before) { if (queue.size() != before) {
writePendingWorldDeleteMap(queue); writePendingWorldDeleteMap(queueFile, queue);
} }
return queue.size() - before; return queue.size() - before;
} }
public void processPendingStartupWorldDeletes() { public synchronized void processPendingStartupWorldDeletes() {
try { try {
try { unregisterTransientStudioWorlds();
int unregistered = IrisCreator.removeTransientStudioWorldsFromBukkitYml();
if (unregistered > 0) {
Iris.info("Unregistered " + unregistered + " transient studio world(s) from bukkit.yml on startup.");
}
} catch (Throwable e) {
Iris.reportError("Failed to unregister transient studio worlds from bukkit.yml on startup.", e);
}
LinkedHashMap<String, String> queue = loadPendingWorldDeleteMap(); File levelRoot = IrisWorldStorage.levelRoot();
for (String transientStudioWorld : TransientWorldCleanupSupport.collectTransientStudioWorldNames(IrisWorldStorage.levelRoot())) { File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE);
queue.putIfAbsent(transientStudioWorld.toLowerCase(Locale.ROOT), transientStudioWorld); LinkedHashMap<String, String> queue = loadPendingWorldDeleteMap(queueFile, levelRoot.getName());
for (String discoveredName : discoverStartupWorldNames(levelRoot)) {
mergeQueueEntry(queue, discoveredName);
} }
if (queue.isEmpty()) { if (queue.isEmpty()) {
if (queueFile.exists()) {
writePendingWorldDeleteMap(queueFile, queue);
}
return; return;
} }
LinkedHashMap<String, String> remaining = new LinkedHashMap<>(); LinkedHashMap<String, String> remaining = new LinkedHashMap<>();
for (String worldName : queue.values()) { for (String worldName : queue.values()) {
if (worldName.equalsIgnoreCase(ServerProperties.LEVEL_NAME)) { processEntry(levelRoot, worldName, remaining);
Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is configured as level-name.");
continue;
}
NamespacedKey worldKey = IrisWorldStorage.keyFromName(worldName);
World loaded = WorldIdentity.resolve(worldKey).orElse(null);
if (loaded != null) {
if (TransientWorldCleanupSupport.isTransientStudioWorldName(worldName)) {
try {
PlatformChunkGenerator generator = IrisToolbelt.access(loaded);
if (generator != null) {
generator.close();
}
IrisToolbelt.evacuate(loaded);
Bukkit.unloadWorld(loaded, false);
Iris.info("Unloaded leftover studio world \"" + worldName + "\" for deletion.");
} catch (Throwable e) {
Iris.reportError("Failed to unload leftover studio world \"" + worldName + "\".", e);
}
if (WorldIdentity.resolve(worldKey).isPresent()) {
Iris.warn("Studio world \"" + worldName + "\" is still loaded after unload; will retry next startup.");
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
continue;
}
} else {
Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is currently loaded.");
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
continue;
}
}
boolean foundAny = false;
boolean deletedAll = true;
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
File worldFolder = IrisWorldStorage.dimensionRoot(familyWorldName);
if (!worldFolder.exists()) {
continue;
}
foundAny = true;
IO.delete(worldFolder);
if (worldFolder.exists()) {
deletedAll = false;
Iris.warn("Failed to delete queued world folder \"" + familyWorldName + "\". Retrying on next startup.");
} else {
Iris.info("Deleted queued world folder \"" + familyWorldName + "\".");
}
}
if (!foundAny) {
Iris.info("Queued world deletion skipped for \"" + worldName + "\" (folder missing).");
continue;
}
if (!deletedAll) {
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
continue;
}
}
writePendingWorldDeleteMap(remaining);
} catch (Throwable e) {
Iris.error("Failed to process queued startup world deletions.");
Iris.reportError(e);
e.printStackTrace();
}
}
private LinkedHashMap<String, String> loadPendingWorldDeleteMap() throws IOException {
LinkedHashMap<String, String> queue = new LinkedHashMap<>();
File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE);
if (!queueFile.exists()) {
return queue;
}
try (BufferedReader reader = new BufferedReader(new FileReader(queueFile))) {
String line;
while ((line = reader.readLine()) != null) {
String normalized = normalizeWorldName(line);
if (normalized == null) {
continue;
}
queue.putIfAbsent(normalized.toLowerCase(Locale.ROOT), normalized);
}
}
return queue;
}
private void writePendingWorldDeleteMap(Map<String, String> queue) throws IOException {
File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE);
if (queue.isEmpty()) {
if (queueFile.exists()) {
IO.delete(queueFile);
}
return;
}
File parent = queueFile.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
throw new IOException("Failed to create queue directory: " + parent.getAbsolutePath());
}
try (PrintWriter writer = new PrintWriter(new FileWriter(queueFile))) {
for (String worldName : queue.values()) {
writer.println(worldName);
} }
writePendingWorldDeleteMap(queueFile, remaining);
} catch (Throwable failure) {
Iris.reportError("Failed to process queued startup world deletions.", failure);
} }
} }
@Nullable @Nullable
private static String normalizeWorldName(String worldName) { static String normalizeQueueEntry(String worldName, String levelName) {
if (worldName == null) { if (worldName == null) {
return null; return null;
} }
String trimmed = worldName.trim(); String candidate = worldName.trim();
if (trimmed.isEmpty()) { if (candidate.isEmpty()) {
return null;
}
if (QUARANTINE_NAME.matcher(candidate).matches()) {
return candidate;
}
String logicalName = candidate.startsWith("iris:") ? candidate.substring("iris:".length()) : candidate;
if (!SAFE_LOGICAL_NAME.matcher(logicalName).matches()) {
return null;
}
String normalizedLevelName = Objects.requireNonNull(levelName, "levelName").trim().toLowerCase(Locale.ROOT);
if (VANILLA_DIMENSION_ALIASES.contains(logicalName)
|| logicalName.equals(normalizedLevelName)
|| logicalName.equals(normalizedLevelName + "_nether")
|| logicalName.equals(normalizedLevelName + "_the_end")) {
return null; return null;
} }
return trimmed; try {
NamespacedKey key = IrisWorldStorage.managedKeyFromName(candidate, normalizedLevelName);
return key.getKey().equals(logicalName) ? logicalName : null;
} catch (IllegalArgumentException failure) {
return null;
}
}
static LinkedHashMap<String, String> loadPendingWorldDeleteMap(File queueFile, String levelName) throws IOException {
LinkedHashMap<String, String> queue = new LinkedHashMap<>();
Path queuePath = Objects.requireNonNull(queueFile, "queueFile").toPath();
if (!Files.exists(queuePath, LinkOption.NOFOLLOW_LINKS)) {
return queue;
}
try (BufferedReader reader = Files.newBufferedReader(queuePath, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
String normalized = normalizeStoredQueueEntry(line, levelName);
if (normalized != null) {
mergeQueueEntry(queue, normalized);
}
}
}
return queue;
}
@Nullable
private static String normalizeStoredQueueEntry(String storedEntry, String levelName) {
if (storedEntry == null) {
return null;
}
String candidate = storedEntry.trim();
boolean exact = candidate.startsWith(EXACT_PREFIX);
String rawName = exact ? candidate.substring(EXACT_PREFIX.length()) : candidate;
String normalized = normalizeQueueEntry(rawName, levelName);
if (normalized == null || QUARANTINE_NAME.matcher(normalized).matches()) {
return normalized;
}
return exact ? EXACT_PREFIX + normalized : normalized;
}
private static void mergeQueueEntry(Map<String, String> queue, String storedEntry) {
String logicalKey = storedEntry.startsWith(EXACT_PREFIX)
? storedEntry.substring(EXACT_PREFIX.length())
: storedEntry;
String key = logicalKey.toLowerCase(Locale.ROOT);
String existing = queue.get(key);
if (existing == null || (existing.startsWith(EXACT_PREFIX) && !storedEntry.startsWith(EXACT_PREFIX))) {
queue.put(key, storedEntry);
}
}
static void writePendingWorldDeleteMap(File queueFile, Map<String, String> queue) throws IOException {
Path queuePath = Objects.requireNonNull(queueFile, "queueFile").toPath().toAbsolutePath().normalize();
Path parent = queuePath.getParent();
if (parent == null) {
throw new IOException("Queue file has no parent directory: " + queuePath);
}
Files.createDirectories(parent);
StringBuilder content = new StringBuilder();
for (String worldName : Objects.requireNonNull(queue, "queue").values()) {
content.append(worldName).append('\n');
}
Path temporary = parent.resolve(queuePath.getFileName() + ".tmp-" + UUID.randomUUID());
IOException failure = null;
try {
byte[] bytes = content.toString().getBytes(StandardCharsets.UTF_8);
try (FileChannel channel = FileChannel.open(
temporary,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE
)) {
ByteBuffer buffer = ByteBuffer.wrap(bytes);
while (buffer.hasRemaining()) {
channel.write(buffer);
}
channel.force(true);
}
replaceQueueFile(temporary, queuePath);
forceDirectory(parent);
} catch (IOException writeFailure) {
failure = writeFailure;
throw writeFailure;
} finally {
try {
Files.deleteIfExists(temporary);
} catch (IOException cleanupFailure) {
if (failure != null) {
failure.addSuppressed(cleanupFailure);
} else {
throw cleanupFailure;
}
}
}
}
static LinkedHashSet<String> discoverStartupWorldNames(File levelRoot) throws IOException {
LinkedHashSet<String> worldNames = new LinkedHashSet<>();
Path root = Objects.requireNonNull(levelRoot, "levelRoot").toPath().toAbsolutePath().normalize();
Path dimensions = root.resolve("dimensions");
Path irisNamespace = dimensions.resolve("iris");
if (!Files.exists(irisNamespace, LinkOption.NOFOLLOW_LINKS)) {
return worldNames;
}
if (Files.isSymbolicLink(dimensions) || Files.isSymbolicLink(irisNamespace)) {
throw new IOException("Iris dimension storage contains a symbolic link: " + irisNamespace);
}
if (!Files.isDirectory(irisNamespace, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Iris dimension storage is not a directory: " + irisNamespace);
}
try (DirectoryStream<Path> children = Files.newDirectoryStream(irisNamespace)) {
for (Path child : children) {
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
String name = child.getFileName().toString();
if (QUARANTINE_NAME.matcher(name).matches()) {
worldNames.add(name);
continue;
}
String transientBaseName = TransientWorldCleanupSupport.transientStudioBaseWorldName(name);
String normalized = normalizeQueueEntry(transientBaseName, root.getFileName().toString());
if (normalized != null) {
worldNames.add(normalized);
}
}
}
return worldNames;
}
static List<Path> resolveQueueEntryPaths(File levelRoot, String worldName) throws IOException {
QueueEntry entry = QueueEntry.parse(worldName, levelRoot.getName());
List<DeleteTarget> targets = entry.targets(levelRoot);
ArrayList<Path> paths = new ArrayList<>(targets.size());
for (DeleteTarget target : targets) {
paths.add(target.path());
}
return List.copyOf(paths);
}
private static void replaceQueueFile(Path temporary, Path queuePath) throws IOException {
try {
Files.move(temporary, queuePath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException unsupported) {
Files.move(temporary, queuePath, StandardCopyOption.REPLACE_EXISTING);
}
}
private static void forceDirectory(Path directory) throws IOException {
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
}
}
private static void unregisterTransientStudioWorlds() {
try {
int unregistered = IrisCreator.removeTransientStudioWorldsFromBukkitYml();
if (unregistered > 0) {
Iris.info("Unregistered " + unregistered + " transient studio world(s) from bukkit.yml on startup.");
}
} catch (Throwable failure) {
Iris.reportError("Failed to unregister transient studio worlds from bukkit.yml on startup.", failure);
}
}
private static void processEntry(
File levelRoot,
String worldName,
LinkedHashMap<String, String> remaining
) {
try {
QueueEntry entry = QueueEntry.parse(worldName, levelRoot.getName());
List<DeleteTarget> targets = entry.targets(levelRoot);
if (targets.stream().anyMatch(PendingWorldDeleteQueue::isLoaded)) {
Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is currently loaded.");
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
return;
}
boolean foundAny = false;
boolean deletedAll = true;
for (DeleteTarget target : targets) {
Path worldFolder = target.path();
if (!Files.exists(worldFolder, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
if (Files.isSymbolicLink(worldFolder) || !Files.isDirectory(worldFolder, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Queued world target is not a safe directory: " + worldFolder);
}
foundAny = true;
try {
deleteTree(worldFolder);
Iris.info("Deleted queued world folder \"" + worldFolder.getFileName() + "\".");
} catch (IOException failure) {
deletedAll = false;
Iris.reportError("Failed to delete queued world folder \"" + worldFolder + "\".", failure);
}
}
if (!foundAny) {
Iris.info("Queued world deletion skipped for \"" + worldName + "\" (folder missing).");
return;
}
if (!deletedAll) {
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
}
} catch (Throwable failure) {
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
Iris.reportError("Failed to safely process queued world deletion for \"" + worldName + "\".", failure);
}
}
private static boolean isLoaded(DeleteTarget target) {
if (target.key() != null && WorldIdentity.resolve(target.key()).isPresent()) {
return true;
}
Path targetPath = target.path().toAbsolutePath().normalize();
for (World world : Bukkit.getWorlds()) {
if (world.getWorldFolder().toPath().toAbsolutePath().normalize().equals(targetPath)) {
return true;
}
}
return false;
}
private static void deleteTree(Path target) throws IOException {
Files.walkFileTree(target, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException {
if (failure != null) {
throw failure;
}
Files.delete(directory);
return FileVisitResult.CONTINUE;
}
});
}
private enum QueueEntryType {
EXACT,
LOGICAL,
QUARANTINE
}
private record QueueEntry(String storedName, QueueEntryType type) {
private static QueueEntry parse(String worldName, String levelName) {
String stored = normalizeStoredQueueEntry(worldName, levelName);
if (stored == null) {
throw new IllegalArgumentException("Unsafe queued Iris world deletion target: " + worldName);
}
boolean exact = stored.startsWith(EXACT_PREFIX);
String normalized = exact ? stored.substring(EXACT_PREFIX.length()) : stored;
QueueEntryType type = QUARANTINE_NAME.matcher(normalized).matches()
? QueueEntryType.QUARANTINE
: exact ? QueueEntryType.EXACT : QueueEntryType.LOGICAL;
return new QueueEntry(normalized, type);
}
private List<DeleteTarget> targets(File levelRoot) throws IOException {
if (type == QueueEntryType.QUARANTINE) {
return List.of(new DeleteTarget(null, requireSafeQuarantinePath(levelRoot, storedName)));
}
if (type == QueueEntryType.EXACT) {
NamespacedKey key = IrisWorldStorage.managedKeyFromName(storedName, levelRoot.getName());
Path path = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key).toPath();
return List.of(new DeleteTarget(key, path));
}
ArrayList<DeleteTarget> targets = new ArrayList<>(3);
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(storedName)) {
NamespacedKey key = IrisWorldStorage.managedKeyFromName(familyWorldName, levelRoot.getName());
Path path = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key).toPath();
targets.add(new DeleteTarget(key, path));
}
return targets;
}
}
private static Path requireSafeQuarantinePath(File levelRoot, String quarantineName) throws IOException {
if (!QUARANTINE_NAME.matcher(quarantineName).matches()) {
throw new IOException("Invalid Iris quarantine directory name: " + quarantineName);
}
Path root = levelRoot.toPath().toAbsolutePath().normalize();
Path dimensions = root.resolve("dimensions");
Path irisNamespace = dimensions.resolve("iris");
Path target = irisNamespace.resolve(quarantineName).normalize();
if (!Objects.equals(target.getParent(), irisNamespace)) {
throw new IOException("Iris quarantine target escapes its namespace: " + target);
}
for (Path path : List.of(dimensions, irisNamespace, target)) {
if (Files.isSymbolicLink(path)) {
throw new IOException("Iris quarantine storage contains a symbolic link: " + path);
}
}
return target;
}
private record DeleteTarget(@Nullable NamespacedKey key, Path path) {
} }
} }
@@ -21,6 +21,7 @@ package art.arcane.iris.core.commands;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.runtime.ChunkClearer; import art.arcane.iris.core.runtime.ChunkClearer;
import art.arcane.iris.core.runtime.GoldenHashScanner; import art.arcane.iris.core.runtime.GoldenHashScanner;
@@ -216,7 +217,15 @@ public class CommandDeveloper implements DirectorExecutor {
Iris.service(StudioSVC.class).downloadSearch(sender(), pack.getLoadKey(), true); Iris.service(StudioSVC.class).downloadSearch(sender(), pack.getLoadKey(), true);
} }
Iris.service(StudioSVC.class).installIntoWorld(sender(), pack, folder); try (LifecycleOperationCoordinator.Lease lease = LifecycleOperationCoordinator.get().acquire(
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
LifecycleOperationCoordinator.OperationKind.PACK_PUBLISH,
pack.getLoadKey()
)) {
Iris.service(StudioSVC.class).replaceIntoWorld(sender(), pack, folder);
} catch (LifecycleOperationCoordinator.BusyException e) {
sender().sendMessage(C.YELLOW + e.getMessage());
}
} }
@Director(description = "Test", descriptionKey = "iris.director.commanddeveloper.director.test") @Director(description = "Test", descriptionKey = "iris.director.commanddeveloper.director.test")
@@ -125,6 +125,7 @@ public class CommandFind implements DirectorExecutor {
String structureKey = structure == null ? "" : structure.trim(); String structureKey = structure == null ? "" : structure.trim();
Structure nativeStructure = resolveNativeStructure(structureKey); Structure nativeStructure = resolveNativeStructure(structureKey);
boolean irisReplacement = false;
if (nativeStructure != null) { if (nativeStructure != null) {
IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve( IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(
e, structureKey, false); e, structureKey, false);
@@ -134,7 +135,8 @@ public class CommandFind implements DirectorExecutor {
structureKey, decision.status())); structureKey, decision.status()));
return; return;
} }
if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { irisReplacement = decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS;
if (irisReplacement && !IrisStructureLocator.hasNativePlacement(e, structureKey)) {
locateIrisStructure(e, structureKey, commandSender); locateIrisStructure(e, structureKey, commandSender);
return; return;
} }
@@ -149,6 +151,9 @@ public class CommandFind implements DirectorExecutor {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_UNKNOWN_STRUCTURE, MessageArgument.untrusted("structureKey", structureKey))); commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_UNKNOWN_STRUCTURE, MessageArgument.untrusted("structureKey", structureKey)));
return; return;
} }
final boolean replacementLocate = irisReplacement;
final boolean explicitNativePlacement = IrisStructureLocator.hasNativePlacement(
e, structureKey);
Player target = player(); Player target = player();
if (target == null) { if (target == null) {
@@ -161,7 +166,8 @@ public class CommandFind implements DirectorExecutor {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_LOCATING, MessageArgument.untrusted("structureKey", structureKey))); commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_LOCATING, MessageArgument.untrusted("structureKey", structureKey)));
J.s(() -> { J.s(() -> {
try { try {
if (!StructureReachability.isReachable(e, structureKey)) { if (!replacementLocate && !explicitNativePlacement
&& !StructureReachability.isReachable(e, structureKey)) {
KList<String> miss = StructureReachability.missingBiomeKeys(e, structureKey); KList<String> miss = StructureReachability.missingBiomeKeys(e, structureKey);
sendStructureMessage(target, commandSender, sendStructureMessage(target, commandSender,
C.YELLOW + structureKey + " cannot generate in this world (its required biomes are not produced by this pack" C.YELLOW + structureKey + " cannot generate in this world (its required biomes are not produced by this pack"
@@ -19,16 +19,24 @@
package art.arcane.iris.core.commands; package art.arcane.iris.core.commands;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.core.BukkitWorldReconciler;
import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.DatapackInstallResult;
import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.IrisWorlds; import art.arcane.iris.core.IrisWorlds;
import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.IrisWorldRemovalService;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
@@ -41,7 +49,6 @@ import art.arcane.volmlib.util.director.annotations.Param;
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
import art.arcane.iris.util.common.director.specialhandlers.NullablePlayerHandler; import art.arcane.iris.util.common.director.specialhandlers.NullablePlayerHandler;
import art.arcane.iris.util.common.format.C; import art.arcane.iris.util.common.format.C;
import art.arcane.volmlib.util.io.IO;
import art.arcane.iris.util.common.misc.ServerProperties; import art.arcane.iris.util.common.misc.ServerProperties;
import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J; import art.arcane.iris.util.common.scheduling.J;
@@ -49,32 +56,44 @@ import org.bukkit.Bukkit;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.FileOutputStream; import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties; import java.util.Properties;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import static art.arcane.iris.core.service.EditSVC.deletingWorld;
import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML; import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML;
import static org.bukkit.Bukkit.getServer; import static org.bukkit.Bukkit.getServer;
import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.IrisMessages; import art.arcane.iris.core.localization.IrisMessages;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.iris.core.localization.BukkitCommandMessages;
import art.arcane.iris.core.localization.BukkitCommandMessagesExtended; import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
import art.arcane.iris.core.localization.RuntimeUiMessages; import art.arcane.iris.core.localization.RuntimeUiMessages;
@Director(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command", descriptionKey = "iris.director.commandiris.director.basic_command") @Director(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command", descriptionKey = "iris.director.commandiris.director.basic_command")
public class CommandIris implements DirectorExecutor { public class CommandIris implements DirectorExecutor {
private static final long WORLD_UNLOAD_TIMEOUT_SECONDS = 150L;
private CommandStudio studio; private CommandStudio studio;
private CommandPregen pregen; private CommandPregen pregen;
private CommandObject object; private CommandObject object;
@@ -85,10 +104,7 @@ public class CommandIris implements DirectorExecutor {
private CommandPack pack; private CommandPack pack;
private CommandFind find; private CommandFind find;
private CommandDatapack datapack; private CommandDatapack datapack;
public static boolean worldCreation = false;
private static final AtomicReference<Thread> mainWorld = new AtomicReference<>(); private static final AtomicReference<Thread> mainWorld = new AtomicReference<>();
String WorldEngine;
String worldNameToCheck = "YourWorldName";
VolmitSender sender = Iris.getSender(); VolmitSender sender = Iris.getSender();
@Director(description = "Create a new world", descriptionKey = "iris.director.commandiris.director.create_new_world", aliases = {"c"}) @Director(description = "Create a new world", descriptionKey = "iris.director.commandiris.director.create_new_world", aliases = {"c"})
@@ -107,7 +123,15 @@ public class CommandIris implements DirectorExecutor {
@Param(aliases = "main-world", description = "Whether or not to automatically use this world as the main world", descriptionKey = "iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world", defaultValue = "false") @Param(aliases = "main-world", description = "Whether or not to automatically use this world as the main world", descriptionKey = "iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world", defaultValue = "false")
boolean main boolean main
) { ) {
String worldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(name)); NamespacedKey worldKey;
try {
worldKey = IrisWorldStorage.managedKeyFromName(name);
IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
} catch (IllegalArgumentException e) {
sender().sendMessage(C.RED + e.getMessage());
return;
}
String worldName = IrisWorldStorage.logicalName(worldKey);
if (worldName.equalsIgnoreCase("iris")) { if (worldName.equalsIgnoreCase("iris")) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOU_CANNOT_USE_WORLD_NAME_IRIS_CREATING_WORLDS_AS_IRIS)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOU_CANNOT_USE_WORLD_NAME_IRIS_CREATING_WORLDS_AS_IRIS));
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_MAY_WE_SUGGEST_NAME_IRISWORLD_INSTEAD)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_MAY_WE_SUGGEST_NAME_IRISWORLD_INSTEAD));
@@ -145,7 +169,6 @@ public class CommandIris implements DirectorExecutor {
} }
try { try {
worldCreation = true;
IrisToolbelt.createWorld() IrisToolbelt.createWorld()
.dimension(resolvedType) .dimension(resolvedType)
.name(worldName) .name(worldName)
@@ -160,22 +183,46 @@ public class CommandIris implements DirectorExecutor {
})); }));
} }
} catch (Throwable e) { } catch (Throwable e) {
if (reportExpectedCreationInterruption(e)) {
return;
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_EXCEPTION_RAISED_DURING_CREATION_SEE_CONSOLE_MORE_DETAILS)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_EXCEPTION_RAISED_DURING_CREATION_SEE_CONSOLE_MORE_DETAILS));
Iris.reportError("Exception raised during world creation for \"" + worldName + "\".", e); Iris.reportError("Exception raised during world creation for \"" + worldName + "\".", e);
worldCreation = false;
return; return;
} }
worldCreation = false;
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_SUCCESSFULLY_CREATED_YOUR_WORLD)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_SUCCESSFULLY_CREATED_YOUR_WORLD));
if (main) sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOUR_WORLD_WILL_AUTOMATICALLY_BE_SET_AS_MAIN_WORLD_WHEN)); if (main) sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOUR_WORLD_WILL_AUTOMATICALLY_BE_SET_AS_MAIN_WORLD_WHEN));
} }
private boolean updateMainWorld(String newName) { private boolean updateMainWorld(String newName) {
LifecycleOperationCoordinator.Lease lease;
try {
lease = LifecycleOperationCoordinator.get().acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_PROMOTE,
newName
);
} catch (LifecycleOperationCoordinator.BusyException e) {
Iris.error("Could not promote Iris world \"" + newName + "\": " + e.getMessage());
return false;
}
try {
return updateMainWorldUnderLease(newName);
} finally {
lease.close();
}
}
private boolean updateMainWorldUnderLease(String newName) {
try { try {
File oldLevelRoot = IrisWorldStorage.levelRoot(); File oldLevelRoot = IrisWorldStorage.levelRoot();
File worldContainer = oldLevelRoot.getParentFile(); File worldContainer = oldLevelRoot.getParentFile();
Properties data = ServerProperties.DATA; if (worldContainer == null) {
throw new IllegalStateException("Current level folder has no world container.");
}
Properties data = new Properties();
try (FileInputStream in = new FileInputStream(ServerProperties.SERVER_PROPERTIES)) { try (FileInputStream in = new FileInputStream(ServerProperties.SERVER_PROPERTIES)) {
data.load(in); data.load(in);
} }
@@ -186,22 +233,6 @@ public class CommandIris implements DirectorExecutor {
} }
File newLevelRoot = new File(worldContainer, newName); File newLevelRoot = new File(worldContainer, newName);
if (!newLevelRoot.exists() && !newLevelRoot.mkdirs()) {
throw new IllegalStateException("Could not create target level folder: " + newLevelRoot.getAbsolutePath());
}
for (String sub : List.of("data", "datapacks", "players")) {
File source = new File(oldLevelRoot, sub);
if (!source.exists()) {
continue;
}
IO.copyDirectory(source.toPath(), new File(newLevelRoot, sub).toPath());
}
File targetDimensionRoot = IrisWorldStorage.dimensionRoot(newLevelRoot, NamespacedKey.minecraft("overworld"));
IO.copyDirectory(sourceDimensionRoot.toPath(), targetDimensionRoot.toPath());
World sourceWorld = WorldIdentity.resolve(IrisWorldStorage.keyFromName(newName)).orElse(null); World sourceWorld = WorldIdentity.resolve(IrisWorldStorage.keyFromName(newName)).orElse(null);
Long stagedSeed = IrisWorlds.readBukkitWorldSeed(newName); Long stagedSeed = IrisWorlds.readBukkitWorldSeed(newName);
if (sourceWorld == null && stagedSeed == null) { if (sourceWorld == null && stagedSeed == null) {
@@ -210,8 +241,18 @@ public class CommandIris implements DirectorExecutor {
long promotedSeed = sourceWorld == null ? stagedSeed : sourceWorld.getSeed(); long promotedSeed = sourceWorld == null ? stagedSeed : sourceWorld.getSeed();
data.setProperty("level-name", newName); data.setProperty("level-name", newName);
data.setProperty("level-seed", Long.toString(promotedSeed)); data.setProperty("level-seed", Long.toString(promotedSeed));
try (FileOutputStream out = new FileOutputStream(ServerProperties.SERVER_PROPERTIES)) {
data.store(out, null); try (MainWorldPublication publication = publishMainWorldFiles(
oldLevelRoot.toPath(),
sourceDimensionRoot.toPath(),
newLevelRoot.toPath()
)) {
writeServerPropertiesAtomically(ServerProperties.SERVER_PROPERTIES.toPath(), data);
publication.commit();
}
synchronized (ServerProperties.DATA) {
ServerProperties.DATA.clear();
ServerProperties.DATA.putAll(data);
} }
return true; return true;
} catch (Throwable e) { } catch (Throwable e) {
@@ -221,57 +262,204 @@ public class CommandIris implements DirectorExecutor {
} }
} }
private boolean stageFoliaWorldCreation(String name, IrisDimension dimension, long seed, boolean main) { static MainWorldPublication publishMainWorldFiles(
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_RUNTIME_WORLD_CREATION_IS_DISABLED_ON_FOLIA)); Path currentLevelRoot,
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_PREPARING_WORLD_FILES_BUKKIT_YML_NEXT_STARTUP)); Path sourceDimensionRoot,
Path targetLevelRoot
File worldFolder = IrisWorldStorage.dimensionRoot(name); ) throws IOException {
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension, worldFolder); Path current = Objects.requireNonNull(currentLevelRoot, "currentLevelRoot").toAbsolutePath().normalize();
if (installed == null) { Path sourceDimension = Objects.requireNonNull(sourceDimensionRoot, "sourceDimensionRoot").toAbsolutePath().normalize();
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_STAGE_WORLD_FILES_DIMENSION, MessageArgument.untrusted("value", dimension.getLoadKey()))); Path target = Objects.requireNonNull(targetLevelRoot, "targetLevelRoot").toAbsolutePath().normalize();
return false; Path worldContainer = current.getParent();
Path sourceNamespace = current.resolve("dimensions/iris");
if (worldContainer == null || !Objects.equals(target.getParent(), worldContainer)) {
throw new IOException("Promoted main world must be a direct child of the world container.");
} }
if (!Objects.equals(sourceDimension.getParent(), sourceNamespace)) {
if (!registerWorldInBukkitYml(name, dimension.getLoadKey(), seed)) { throw new IOException("Promoted source must be a direct Iris dimension.");
return false;
} }
if (Objects.equals(current, target)) {
throw new IOException("Promoted main world cannot replace the current main world.");
}
if (Files.isSymbolicLink(worldContainer)
|| Files.isSymbolicLink(current)
|| !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Current world storage is missing or unsafe.");
}
if (Files.isSymbolicLink(sourceDimension)
|| !Files.isDirectory(sourceDimension, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Promoted Iris dimension is missing or unsafe: " + sourceDimension);
}
requireAbsentMainWorldTarget(target);
if (main) { Path stage = Files.createTempDirectory(worldContainer, "." + target.getFileName() + ".promoting-");
if (updateMainWorld(name)) { boolean published = false;
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UPDATED_SERVER_PROPERTIES_LEVEL_NAME, MessageArgument.untrusted("name", name))); try {
} else { for (String subdirectory : List.of("data", "datapacks", "players")) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_WAS_STAGED_BUT_FAILED_UPDATE_SERVER_PROPERTIES_MAIN_WORLD)); Path source = current.resolve(subdirectory);
return false; if (!Files.exists(source, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(source)) {
continue;
}
copyWorldTree(source, stage.resolve(subdirectory));
}
Path targetDimension = IrisWorldStorage.dimensionRoot(
stage.toFile(),
NamespacedKey.minecraft("overworld")
).toPath();
copyWorldTree(sourceDimension, targetDimension);
requireAbsentMainWorldTarget(target);
Files.move(stage, target);
published = true;
return new MainWorldPublication(target);
} finally {
if (!published) {
AtomicDirectoryPublisher.deleteTree(stage);
} }
} }
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_STAGED_IRIS_WORLD_WITH_GENERATOR_IRIS_SEED, MessageArgument.untrusted("name", name), MessageArgument.untrusted("value", dimension.getLoadKey()), MessageArgument.untrusted("seed", seed))); private static void requireAbsentMainWorldTarget(Path target) throws IOException {
if (main) { if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_WORLD_IS_NOW_CONFIGURED_AS_MAIN_NEXT_RESTART)); throw new FileAlreadyExistsException("Main-world target already exists: " + target);
}
}
private static void copyWorldTree(Path source, Path target) throws IOException {
if (Files.isSymbolicLink(source)) {
throw new IOException("World data contains a symbolic link: " + source);
}
if (Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(target.getParent());
Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES);
return;
}
if (!Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("World data contains an unsupported entry: " + source);
}
try (Stream<Path> entries = Files.walk(source)) {
for (Path entry : entries.sorted(Comparator.naturalOrder()).toList()) {
if (Files.isSymbolicLink(entry)) {
throw new IOException("World data contains a symbolic link: " + entry);
}
Path destination = target.resolve(source.relativize(entry)).normalize();
if (!destination.startsWith(target)) {
throw new IOException("World data escapes its promotion stage: " + entry);
}
if (Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(destination);
} else if (Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(destination.getParent());
Files.copy(entry, destination, StandardCopyOption.COPY_ATTRIBUTES);
} else {
throw new IOException("World data contains an unsupported entry: " + entry);
}
}
}
}
private static void writeServerPropertiesAtomically(Path propertiesFile, Properties data) throws IOException {
Path target = propertiesFile.toAbsolutePath().normalize();
Path parent = target.getParent();
if (parent == null) {
throw new IOException("server.properties has no parent directory.");
}
Path stage = Files.createTempFile(parent, ".server.properties.promoting-", ".tmp");
IOException operationFailure = null;
try {
try (FileOutputStream out = new FileOutputStream(stage.toFile())) {
data.store(out, null);
out.getFD().sync();
}
try {
Files.move(stage, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(stage, target, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
operationFailure = e;
throw e;
} finally {
try {
Files.deleteIfExists(stage);
} catch (IOException cleanupFailure) {
if (operationFailure != null) {
operationFailure.addSuppressed(cleanupFailure);
} else {
throw cleanupFailure;
}
}
}
}
private boolean stageFoliaWorldCreation(String name, IrisDimension dimension, long seed, boolean main) {
NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(name);
LifecycleOperationCoordinator.Lease worldLease = null;
File worldFolder = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
try {
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
worldLease = coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
worldKey.toString());
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_RUNTIME_WORLD_CREATION_IS_DISABLED_ON_FOLIA));
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_PREPARING_WORLD_FILES_BUKKIT_YML_NEXT_STARTUP));
if (worldFolder.exists()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THAT_FOLDER_ALREADY_EXISTS));
return false;
}
DatapackInstallResult datapackResult = ServerConfigurator.installDataPacksIfChanged(true);
if (!datapackResult.succeeded()) {
sender().sendMessage(C.RED + "Failed to compile the Iris datapack. No world files were staged.");
return false;
}
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension, worldFolder);
if (installed == null) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_STAGE_WORLD_FILES_DIMENSION, MessageArgument.untrusted("value", dimension.getLoadKey())));
deleteDirectorySafely(worldFolder);
return false;
}
if (!registerWorldInBukkitYml(name, dimension.getLoadKey(), seed)) {
deleteDirectorySafely(worldFolder);
return false;
}
if (main) {
if (updateMainWorldUnderLease(name)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UPDATED_SERVER_PROPERTIES_LEVEL_NAME, MessageArgument.untrusted("name", name)));
} else {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_WAS_STAGED_BUT_FAILED_UPDATE_SERVER_PROPERTIES_MAIN_WORLD));
try {
BukkitWorldConfiguration.remove(BUKKIT_YML, name);
} catch (IOException e) {
Iris.reportError("Failed to roll back bukkit.yml after main-world staging failed.", e);
}
deleteDirectorySafely(worldFolder);
return false;
}
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_STAGED_IRIS_WORLD_WITH_GENERATOR_IRIS_SEED, MessageArgument.untrusted("name", name), MessageArgument.untrusted("value", dimension.getLoadKey()), MessageArgument.untrusted("seed", seed)));
if (main) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_WORLD_IS_NOW_CONFIGURED_AS_MAIN_NEXT_RESTART));
}
return true;
} catch (LifecycleOperationCoordinator.BusyException e) {
sender().sendMessage(C.YELLOW + e.getMessage());
return false;
} finally {
if (worldLease != null) {
worldLease.close();
}
} }
return true;
} }
private boolean registerWorldInBukkitYml(String worldName, String dimension, Long seed) { private boolean registerWorldInBukkitYml(String worldName, String dimension, Long seed) {
String logicalWorldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(worldName)); String logicalWorldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(worldName));
YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML);
ConfigurationSection worlds = yml.getConfigurationSection("worlds");
if (worlds == null) {
worlds = yml.createSection("worlds");
}
ConfigurationSection worldSection = worlds.getConfigurationSection(logicalWorldName);
if (worldSection == null) {
worldSection = worlds.createSection(logicalWorldName);
}
String generator = "Iris:" + dimension;
worldSection.set("generator", generator);
if (seed != null) {
worldSection.set("seed", seed);
}
try { try {
yml.save(BUKKIT_YML); BukkitWorldConfiguration.register(BUKKIT_YML, logicalWorldName, dimension, seed);
Iris.info("Registered \"" + logicalWorldName + "\" in bukkit.yml"); Iris.info("Registered \"" + logicalWorldName + "\" in bukkit.yml");
return true; return true;
} catch (IOException e) { } catch (IOException e) {
@@ -282,6 +470,31 @@ public class CommandIris implements DirectorExecutor {
} }
} }
private void deleteDirectorySafely(File directory) {
try {
AtomicDirectoryPublisher.deleteTree(directory.toPath());
} catch (IOException e) {
Iris.reportError("Failed to roll back staged world folder \"" + directory.getAbsolutePath() + "\".", e);
}
}
private boolean reportExpectedCreationInterruption(Throwable failure) {
Throwable current = failure;
while (current != null) {
if (current instanceof LifecycleOperationCoordinator.BusyException) {
sender().sendMessage(C.YELLOW + current.getMessage());
return true;
}
current = current.getCause();
}
String message = failure.getMessage();
if (message != null && message.contains("queued a restart")) {
sender().sendMessage(C.YELLOW + message);
return true;
}
return false;
}
@Director(description = "Teleport to another world", descriptionKey = "iris.director.commandiris.director.teleport_another_world", aliases = {"tp"}, sync = true) @Director(description = "Teleport to another world", descriptionKey = "iris.director.commandiris.director.teleport_another_world", aliases = {"tp"}, sync = true)
public void teleport( public void teleport(
@Param(description = "World to teleport to", descriptionKey = "iris.director.commandiris.param.world_teleport") @Param(description = "World to teleport to", descriptionKey = "iris.director.commandiris.param.world_teleport")
@@ -368,78 +581,73 @@ public class CommandIris implements DirectorExecutor {
@Director(description = "Remove an Iris world", descriptionKey = "iris.director.commandiris.director.remove_iris_world", aliases = {"rm"}, sync = true) @Director(description = "Remove an Iris world", descriptionKey = "iris.director.commandiris.director.remove_iris_world", aliases = {"rm"}, sync = true)
public void remove( public void remove(
@Param(description = "The world to remove", descriptionKey = "iris.director.commandiris.param.world_remove") @Param(description = "The loaded or disk-only Iris world to remove", descriptionKey = "iris.director.commandiris.param.world_remove", customHandler = ManagedWorldNameHandler.class)
World world, String world,
@Param(description = "Whether to also remove the folder (if set to false, just does not load the world)", descriptionKey = "iris.director.commandiris.param.whether_also_remove_folder_if_set_false_just_does_not_load_world", defaultValue = "true") @Param(description = "Whether to also remove the folder (if set to false, just does not load the world)", descriptionKey = "iris.director.commandiris.param.whether_also_remove_folder_if_set_false_just_does_not_load_world", defaultValue = "true")
boolean delete boolean delete
) { ) {
if (!IrisToolbelt.isIrisWorld(world)) { VolmitSender responseSender = sender();
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_IS_NOT_IRIS_WORLD_IRIS_WORLDS, MessageArgument.untrusted("value", String.join(", ", getServer().getWorlds().stream().filter(IrisToolbelt::isIrisWorld).map(World::getName).toList())))); responseSender.sendMessage(C.GRAY + "Removing Iris world '" + world + "'...");
return; IrisWorldRemovalService.get().remove(world, delete).whenComplete((result, throwable) -> {
} Runnable response = () -> reportRemovalResult(responseSender, world, result, throwable);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_REMOVING_WORLD, MessageArgument.untrusted("value", world.getName()))); if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
return;
if (!IrisToolbelt.evacuate(world)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_EVACUATE_WORLD, MessageArgument.untrusted("value", world.getName())));
return;
}
if (!WorldLifecycleService.get().unload(world, false)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD, MessageArgument.untrusted("value", world.getName())));
return;
}
try {
if (IrisToolbelt.removeWorld(world)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_SUCCESSFULLY_REMOVED_FROM_BUKKIT_YML, MessageArgument.untrusted("value", world.getName())));
} else {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOOKS_LIKE_WORLD_WAS_ALREADY_REMOVED_FROM_BUKKIT_YML));
} }
} catch (IOException e) { J.s(response);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_SAVE_BUKKIT_YML_BECAUSE, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
Iris.reportError("Failed to remove world \"" + world.getName() + "\" from bukkit.yml.", e);
}
IrisToolbelt.evacuate(world, "Deleting world");
deletingWorld = true;
if (!delete) {
deletingWorld = false;
return;
}
VolmitSender sender = sender();
J.a(() -> {
int retries = 12;
if (deleteDirectory(world.getWorldFolder())) {
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_IRIS_SUCCESSFULLY_REMOVED_WORLD_FOLDER));
} else {
while(true){
if (deleteDirectory(world.getWorldFolder())){
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_IRIS_SUCCESSFULLY_REMOVED_WORLD_FOLDER_2));
break;
}
retries--;
if (retries == 0){
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_IRIS_FAILED_REMOVE_WORLD_FOLDER));
break;
}
J.sleep(3000);
}
}
deletingWorld = false;
}); });
} }
public static boolean deleteDirectory(File dir) { private void reportRemovalResult(
if (dir.isDirectory()) { VolmitSender responseSender,
File[] children = dir.listFiles(); String requestedWorld,
for (int i = 0; i < children.length; i++) { IrisWorldRemovalService.RemovalResult result,
boolean success = deleteDirectory(children[i]); Throwable throwable
if (!success) { ) {
return false; if (throwable != null || result == null) {
Throwable failure = throwable == null
? new IllegalStateException("World removal returned no result.")
: throwable;
responseSender.sendMessage(C.RED + "World removal failed unexpectedly; nothing further was deleted.");
Iris.reportError("Unexpected world removal failure for \"" + requestedWorld + "\".", failure);
return;
}
switch (result.status()) {
case UNREGISTERED -> responseSender.sendMessage(C.GREEN + "Unloaded and unregistered '"
+ result.target().logicalName() + "'; its files were preserved.");
case DELETED -> responseSender.sendMessage(C.GREEN + "Removed Iris world '"
+ result.target().logicalName() + "' and deleted its folder.");
case DELETE_QUEUED -> responseSender.sendMessage(C.YELLOW + "Removed Iris world '"
+ result.target().logicalName() + "'; its quarantined folder will be deleted at startup.");
case BUSY -> responseSender.sendMessage(C.YELLOW + "World changes are busy with "
+ result.blockingOperation().kind().name().toLowerCase(Locale.ROOT) + " for '"
+ result.blockingOperation().target() + "'. Try again when it completes.");
case INVALID_IDENTIFIER, PROTECTED_WORLD, NOT_IRIS_WORLD, UNSAFE_PATH, NOT_FOUND ->
responseSender.sendMessage(C.RED + removalFailureDetail(result));
default -> {
responseSender.sendMessage(C.RED + "World removal stopped at "
+ result.status().name().toLowerCase(Locale.ROOT) + ": " + removalFailureDetail(result));
if (result.quarantineDirectory() != null) {
responseSender.sendMessage(C.YELLOW + "The recoverable world folder is "
+ result.quarantineDirectory().toAbsolutePath() + ".");
} else if (result.configurationChanged() || result.registryChanged()) {
responseSender.sendMessage(C.YELLOW + "Removal changed registration state before stopping; "
+ "the original world folder was not deleted.");
}
if (result.failure() != null) {
Iris.reportError("World removal failed for \"" + requestedWorld + "\" at "
+ result.status().name() + ".", result.failure());
} }
} }
} }
return dir.delete(); }
private String removalFailureDetail(IrisWorldRemovalService.RemovalResult result) {
Throwable failure = result.failure();
if (failure == null || failure.getMessage() == null || failure.getMessage().isBlank()) {
return result.status().name().toLowerCase(Locale.ROOT).replace('_', ' ');
}
return failure.getMessage();
} }
@Director(description = "Toggle debug", descriptionKey = "iris.director.commandiris.director.toggle_debug") @Director(description = "Toggle debug", descriptionKey = "iris.director.commandiris.director.toggle_debug")
@@ -466,7 +674,6 @@ public class CommandIris implements DirectorExecutor {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOWNLOADING_PACK, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("branch", branch), MessageArgument.trusted("value", overwrite ? IrisLanguage.text(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) : ""))); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOWNLOADING_PACK, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("branch", branch), MessageArgument.trusted("value", overwrite ? IrisLanguage.text(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) : "")));
Iris.service(StudioSVC.class).downloadSearch(sender(), "IrisDimensions/" + pack + "/" + branch, overwrite); Iris.service(StudioSVC.class).downloadSearch(sender(), "IrisDimensions/" + pack + "/" + branch, overwrite);
} }
ServerConfigurator.installDataPacksIfChanged(true);
} }
@Director(description = "Get metrics for your world", descriptionKey = "iris.director.commandiris.director.get_metrics_your_world", aliases = "measure", origin = DirectorOrigin.PLAYER) @Director(description = "Get metrics for your world", descriptionKey = "iris.director.commandiris.director.get_metrics_your_world", aliases = "measure", origin = DirectorOrigin.PLAYER)
@@ -508,76 +715,187 @@ public class CommandIris implements DirectorExecutor {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_IS_NOT_IRIS_WORLD_IRIS_WORLDS_2, MessageArgument.untrusted("value", String.join(", ", getServer().getWorlds().stream().filter(IrisToolbelt::isIrisWorld).map(World::getName).toList())))); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_IS_NOT_IRIS_WORLD_IRIS_WORLDS_2, MessageArgument.untrusted("value", String.join(", ", getServer().getWorlds().stream().filter(IrisToolbelt::isIrisWorld).map(World::getName).toList()))));
return; return;
} }
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UNLOADING_WORLD, MessageArgument.untrusted("value", world.getName()))); VolmitSender responseSender = sender();
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UNLOADING_WORLD, MessageArgument.untrusted("value", world.getName())));
LifecycleOperationCoordinator.Lease lease;
try { try {
IrisToolbelt.evacuate(world); lease = LifecycleOperationCoordinator.get().acquire(
boolean unloaded = WorldLifecycleService.get().unload(world, false); LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
if (unloaded) { LifecycleOperationCoordinator.OperationKind.WORLD_UNLOAD,
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_UNLOADED_SUCCESSFULLY)); WorldIdentity.serialize(world)
} else { );
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_2)); } catch (LifecycleOperationCoordinator.BusyException e) {
} responseSender.sendMessage(C.YELLOW + e.getMessage());
return;
}
PlatformChunkGenerator generator = IrisToolbelt.access(world);
IrisToolbelt.beginWorldMaintenance(world, "world-unload", true);
try {
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
CompletableFuture<Boolean> sequence = IrisToolbelt.evacuateAsync(world)
.thenCompose(evacuated -> {
if (terminalTimeout.get()) {
return CompletableFuture.failedFuture(new TimeoutException(
"World unload stopped after its terminal timeout."));
}
if (!Boolean.TRUE.equals(evacuated)) {
return CompletableFuture.completedFuture(false);
}
return WorldLifecycleService.get().unloadAsync(world, true);
})
.thenCompose(unloaded -> {
if (terminalTimeout.get()) {
return CompletableFuture.failedFuture(new TimeoutException(
"World unload stopped after its terminal timeout."));
}
if (!Boolean.TRUE.equals(unloaded) || generator == null) {
return CompletableFuture.completedFuture(Boolean.TRUE.equals(unloaded));
}
return generator.closeAsync().thenApply(ignored -> true);
});
guardUnloadCompletion(sequence, terminalTimeout, world.getName())
.whenComplete((unloaded, throwable) -> {
IrisToolbelt.endWorldMaintenance(world, "world-unload");
lease.close();
Runnable response = () -> reportUnloadResult(responseSender, world, unloaded, throwable);
if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
return;
}
J.s(response);
});
} catch (Exception e) { } catch (Exception e) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage())))); IrisToolbelt.endWorldMaintenance(world, "world-unload");
lease.close();
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e); Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e);
} }
} }
private CompletableFuture<Boolean> guardUnloadCompletion(
CompletableFuture<Boolean> source,
AtomicBoolean terminalTimeout,
String worldName
) {
CompletableFuture<Boolean> guarded = new CompletableFuture<>();
AtomicBoolean settled = new AtomicBoolean(false);
source.whenComplete((unloaded, throwable) -> {
if (!settled.compareAndSet(false, true)) {
return;
}
if (throwable == null) {
guarded.complete(Boolean.TRUE.equals(unloaded));
} else {
guarded.completeExceptionally(throwable);
}
});
CompletableFuture.delayedExecutor(WORLD_UNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> {
if (!settled.compareAndSet(false, true)) {
return;
}
terminalTimeout.set(true);
TimeoutException timeout = new TimeoutException(
"World unload did not settle within " + WORLD_UNLOAD_TIMEOUT_SECONDS
+ " seconds for \"" + worldName + "\".");
ServerConfigurator.restart("World unload timed out for \"" + worldName + "\".");
guarded.completeExceptionally(timeout);
});
return guarded;
}
private void reportUnloadResult(VolmitSender responseSender, World world, Boolean unloaded, Throwable throwable) {
if (throwable != null) {
responseSender.sendMessage(IrisLanguage.text(
BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3,
MessageArgument.untrusted("value", String.valueOf(throwable.getMessage()))
));
Iris.reportError("Failed to unload world \"" + world.getName() + "\".", throwable);
return;
}
if (Boolean.TRUE.equals(unloaded)) {
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_UNLOADED_SUCCESSFULLY));
} else {
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_2));
}
}
@Director(description = "Load an Iris World", descriptionKey = "iris.director.commandiris.director.load_iris_world", origin = DirectorOrigin.PLAYER, sync = true, aliases = {"import"}) @Director(description = "Load an Iris World", descriptionKey = "iris.director.commandiris.director.load_iris_world", origin = DirectorOrigin.PLAYER, sync = true, aliases = {"import"})
public void loadWorld( public void loadWorld(
@Param(description = "The name of the world to load", descriptionKey = "iris.director.commandiris.param.name_world_load") @Param(
description = "The name of the world to load",
descriptionKey = "iris.director.commandiris.param.name_world_load",
customHandler = ManagedWorldNameHandler.class)
String world String world
) { ) {
String logicalWorldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(world)); NamespacedKey worldKey;
worldNameToCheck = logicalWorldName; try {
boolean worldExists = doesWorldExist(worldNameToCheck); worldKey = IrisWorldStorage.managedKeyFromName(world);
WorldEngine = logicalWorldName; IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
} catch (IllegalArgumentException failure) {
sender().sendMessage(C.RED + failure.getMessage());
return;
}
String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
boolean worldExists = doesWorldExist(logicalWorldName);
if (!worldExists) { if (!worldExists) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOESNT_EXIST_ON_SERVER, MessageArgument.untrusted("logicalWorldName", logicalWorldName))); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOESNT_EXIST_ON_SERVER, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
return; return;
} }
File directory = new File(IrisWorldStorage.packRoot(IrisWorldStorage.keyFromName(logicalWorldName)), "dimensions"); VolmitSender responseSender = sender();
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOADING_WORLD, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
String dimension = null; Iris.instance.worldReconciler()
if (directory.exists() && directory.isDirectory()) { .loadWorld(BUKKIT_YML, worldKey.toString())
File[] files = directory.listFiles(); .whenComplete((result, failure) -> {
if (files != null) { Runnable response = () -> reportLoadWorldResult(
for (File file : files) { responseSender,
if (file.isFile()) { logicalWorldName,
String fileName = file.getName(); result,
if (fileName.endsWith(".json")) { failure);
dimension = fileName.substring(0, fileName.length() - 5); if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_GENERATOR, MessageArgument.untrusted("dimension", dimension))); return;
}
} }
} J.s(response);
} });
} else {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_IS_NOT_IRIS_WORLD, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
return;
}
if (dimension == null) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_COULD_NOT_DETERMINE_IRIS_DIMENSION, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
return;
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOADING_WORLD, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
if (!registerWorldInBukkitYml(logicalWorldName, dimension, null)) {
return;
}
if (J.isFolia()) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FOLIA_CANNOT_LOAD_NEW_WORLDS_AT_RUNTIME_RESTART_SERVER_LOAD, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
return;
}
Iris.instance.worldReconciler().checkForBukkitWorlds(logicalWorldName::equals);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOADED_SUCCESSFULLY, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
} }
private void reportLoadWorldResult(
VolmitSender responseSender,
String logicalWorldName,
BukkitWorldReconciler.LoadResult result,
Throwable failure
) {
if (failure != null) {
responseSender.sendMessage(C.RED + "Failed to load Iris world \"" + logicalWorldName + "\": " + failure.getMessage());
Iris.reportError("Failed to load Iris world \"" + logicalWorldName + "\".", failure);
return;
}
if (result == null) {
IllegalStateException missingResult = new IllegalStateException("World load completed without a result.");
responseSender.sendMessage(C.RED + missingResult.getMessage());
Iris.reportError("Failed to load Iris world \"" + logicalWorldName + "\".", missingResult);
return;
}
if (result.succeeded()) {
responseSender.sendMessage(IrisLanguage.text(
BukkitCommandMessagesExtended.COMMAND_IRIS_LOADED_SUCCESSFULLY,
MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
return;
}
C color = result.status() == BukkitWorldReconciler.ReconciliationStatus.BUSY
|| result.status() == BukkitWorldReconciler.ReconciliationStatus.RESTART_REQUIRED
? C.YELLOW
: C.RED;
responseSender.sendMessage(color + result.message());
Throwable resultFailure = result.failure();
if (resultFailure != null
&& result.status() != BukkitWorldReconciler.ReconciliationStatus.BUSY
&& result.status() != BukkitWorldReconciler.ReconciliationStatus.RESTART_REQUIRED) {
Iris.reportError("Failed to load Iris world \"" + logicalWorldName + "\".", resultFailure);
}
}
@Director(description = "Evacuate an iris world", descriptionKey = "iris.director.commandiris.director.evacuate_iris_world", origin = DirectorOrigin.PLAYER, sync = true) @Director(description = "Evacuate an iris world", descriptionKey = "iris.director.commandiris.director.evacuate_iris_world", origin = DirectorOrigin.PLAYER, sync = true)
public void evacuate( public void evacuate(
@Param(description = "Evacuate the world", descriptionKey = "iris.director.commandiris.param.evacuate_world") @Param(description = "Evacuate the world", descriptionKey = "iris.director.commandiris.param.evacuate_world")
@@ -596,6 +914,54 @@ public class CommandIris implements DirectorExecutor {
return worldDirectory.exists() && worldDirectory.isDirectory(); return worldDirectory.exists() && worldDirectory.isDirectory();
} }
public static class ManagedWorldNameHandler implements DirectorParameterHandler<String> {
@Override
public KList<String> getPossibilities() {
Set<String> options = new LinkedHashSet<>();
for (World world : Bukkit.getWorlds()) {
if (IrisToolbelt.isIrisWorld(world)) {
options.add(IrisWorldStorage.logicalName(world));
}
}
for (String identity : IrisWorlds.get().getWorlds().keySet()) {
try {
options.add(IrisWorldStorage.logicalName(WorldIdentity.parse(identity)));
} catch (IllegalArgumentException ignored) {
}
}
File namespace = new File(IrisWorldStorage.levelRoot(), "dimensions/iris");
File[] diskWorlds = namespace.listFiles(File::isDirectory);
if (diskWorlds != null) {
for (File diskWorld : diskWorlds) {
if (!Files.isSymbolicLink(diskWorld.toPath())
&& diskWorld.getName().matches("[a-z0-9_-]+")) {
options.add(diskWorld.getName());
}
}
}
return new KList<>(options);
}
@Override
public String toString(String value) {
return value == null ? "" : value;
}
@Override
public String parse(String in, boolean force) throws DirectorParsingException {
if (in == null || in.isBlank()) {
throw new DirectorParsingException("World identifier cannot be empty");
}
return in.trim();
}
@Override
public boolean supports(Class<?> type) {
return type == String.class;
}
}
public static class PackDimensionTypeHandler implements DirectorParameterHandler<String> { public static class PackDimensionTypeHandler implements DirectorParameterHandler<String> {
@Override @Override
public KList<String> getPossibilities() { public KList<String> getPossibilities() {
@@ -603,28 +969,21 @@ public class CommandIris implements DirectorExecutor {
options.add("default"); options.add("default");
File packsFolder = Iris.instance.getDataFolder("packs"); File packsFolder = Iris.instance.getDataFolder("packs");
File[] packs = packsFolder.listFiles(); for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) {
if (packs != null) { options.add(pack.getName());
for (File pack : packs) {
if (pack == null || !pack.isDirectory()) {
continue;
}
options.add(pack.getName()); try {
IrisData data = IrisData.get(pack);
try { for (String key : data.getDimensionLoader().getPossibleKeys()) {
IrisData data = IrisData.get(pack); options.add(key);
for (String key : data.getDimensionLoader().getPossibleKeys()) { options.add(pack.getName() + ":" + key);
options.add(key);
options.add(pack.getName() + ":" + key);
}
} catch (Throwable ex) {
Iris.warn("Failed to read dimension keys from pack %s: %s%s",
pack.getName(),
ex.getClass().getSimpleName(),
ex.getMessage() == null ? "" : " - " + ex.getMessage());
Iris.reportError(ex);
} }
} catch (Throwable ex) {
Iris.warn("Failed to read dimension keys from pack %s: %s%s",
pack.getName(),
ex.getClass().getSimpleName(),
ex.getMessage() == null ? "" : " - " + ex.getMessage());
Iris.reportError(ex);
} }
} }
@@ -650,4 +1009,32 @@ public class CommandIris implements DirectorExecutor {
return type == String.class; return type == String.class;
} }
} }
static final class MainWorldPublication implements AutoCloseable {
private final Path target;
private boolean committed;
private boolean closed;
MainWorldPublication(Path target) {
this.target = target;
}
void commit() {
if (closed) {
throw new IllegalStateException("Main-world publication is already closed.");
}
committed = true;
}
@Override
public void close() throws IOException {
if (closed) {
return;
}
closed = true;
if (!committed) {
AtomicDirectoryPublisher.deleteTree(target);
}
}
}
} }
@@ -22,6 +22,7 @@ import art.arcane.iris.Iris;
import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.core.link.WorldEditLink; import art.arcane.iris.core.link.WorldEditLink;
import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.runtime.ObjectStudioActivation; import art.arcane.iris.core.runtime.ObjectStudioActivation;
import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.core.runtime.WorldRuntimeControlService;
import art.arcane.iris.core.service.ObjectSVC; import art.arcane.iris.core.service.ObjectSVC;
@@ -73,7 +74,6 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
@@ -109,25 +109,20 @@ public class CommandObject implements DirectorExecutor {
sources.put(data.getDataFolder().getName(), data); sources.put(data.getDataFolder().getName(), data);
} else { } else {
File workspace = Iris.service(StudioSVC.class).getWorkspaceFolder(); File workspace = Iris.service(StudioSVC.class).getWorkspaceFolder();
File[] packs = workspace == null ? null : workspace.listFiles(); for (File pack : PackDirectoryResolver.listVisiblePackDirectories(workspace)) {
if (packs != null) { File dimensionsDir = new File(pack, "dimensions");
Arrays.sort(packs, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)); if (!dimensionsDir.isDirectory()) continue;
for (File pack : packs) { IrisData data = IrisData.get(pack);
if (!pack.isDirectory()) continue; String[] keys = data.getObjectLoader().getPossibleKeys();
File dimensionsDir = new File(pack, "dimensions"); if (keys == null || keys.length == 0) continue;
if (!dimensionsDir.isDirectory()) continue; sources.put(pack.getName(), data);
IrisData data = IrisData.get(pack); if (hostDimension == null) {
String[] keys = data.getObjectLoader().getPossibleKeys(); File[] dimFiles = dimensionsDir.listFiles((f) -> f.isFile() && f.getName().endsWith(".json"));
if (keys == null || keys.length == 0) continue; if (dimFiles != null && dimFiles.length > 0) {
sources.put(pack.getName(), data); String loadKey = dimFiles[0].getName().replaceFirst("\\.json$", "");
if (hostDimension == null) { IrisDimension loaded = data.getDimensionLoader().load(loadKey);
File[] dimFiles = dimensionsDir.listFiles((f) -> f.isFile() && f.getName().endsWith(".json")); if (loaded != null) {
if (dimFiles != null && dimFiles.length > 0) { hostDimension = loaded;
String loadKey = dimFiles[0].getName().replaceFirst("\\.json$", "");
IrisDimension loaded = data.getDimensionLoader().load(loadKey);
if (loaded != null) {
hostDimension = loaded;
}
} }
} }
} }
@@ -394,11 +389,7 @@ public class CommandObject implements DirectorExecutor {
private static List<TreePlausibilizeBatch.Target> resolveFromPacks(String target) { private static List<TreePlausibilizeBatch.Target> resolveFromPacks(String target) {
List<TreePlausibilizeBatch.Target> out = new ArrayList<>(); List<TreePlausibilizeBatch.Target> out = new ArrayList<>();
File packsFolder = Iris.instance.getDataFolder("packs"); File packsFolder = Iris.instance.getDataFolder("packs");
File[] packs = packsFolder.listFiles(File::isDirectory); for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) {
if (packs == null) {
return out;
}
for (File pack : packs) {
File objectsRoot = new File(pack, "objects"); File objectsRoot = new File(pack, "objects");
if (!objectsRoot.isDirectory()) { if (!objectsRoot.isDirectory()) {
continue; continue;
@@ -52,8 +52,8 @@ public class CommandPack implements DirectorExecutor {
} }
if (pack == null || pack.isBlank()) { if (pack == null || pack.isBlank()) {
File[] dirs = packsRoot.listFiles(File::isDirectory); List<File> dirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
if (dirs == null || dirs.length == 0) { if (dirs.isEmpty()) {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_PACKS_VALIDATE)); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_PACKS_VALIDATE));
return; return;
} }
@@ -64,7 +64,7 @@ public class CommandPack implements DirectorExecutor {
broken++; broken++;
} }
} }
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_VALIDATION_COMPLETE_BROKEN_PACKS, MessageArgument.untrusted("broken", String.valueOf(broken)), MessageArgument.untrusted("value", String.valueOf(dirs.length)))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_VALIDATION_COMPLETE_BROKEN_PACKS, MessageArgument.untrusted("broken", String.valueOf(broken)), MessageArgument.untrusted("value", String.valueOf(dirs.size()))));
return; return;
} }
@@ -153,6 +153,11 @@ public class CommandStructure implements DirectorExecutor {
} }
} }
} }
for (String placedKey : IrisStructureLocator.placedKeys(engine)) {
if (!structureKeys.contains(placedKey)) {
structureKeys.add(placedKey);
}
}
VolmitSender commandSender = sender(); VolmitSender commandSender = sender();
Player target = senderIsPlayer ? player() : null; Player target = senderIsPlayer ? player() : null;
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STRUCTURE_VERIFYING_STRUCTURES_FROM_WITHIN_CHUNKS, MessageArgument.untrusted("value", world.getName()), MessageArgument.untrusted("value2", center.getBlockX()), MessageArgument.untrusted("value3", center.getBlockZ()), MessageArgument.untrusted("searchRadius", searchRadius))); commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STRUCTURE_VERIFYING_STRUCTURES_FROM_WITHIN_CHUNKS, MessageArgument.untrusted("value", world.getName()), MessageArgument.untrusted("value2", center.getBlockX()), MessageArgument.untrusted("value3", center.getBlockZ()), MessageArgument.untrusted("searchRadius", searchRadius)));
@@ -169,7 +174,8 @@ public class CommandStructure implements DirectorExecutor {
for (String keyName : structureKeys) { for (String keyName : structureKeys) {
IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, keyName, false); IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, keyName, false);
decisions.put(keyName, decision); decisions.put(keyName, decision);
requiresNativeReachability |= decision.generate(); requiresNativeReachability |= !IrisStructureLocator.isPlaced(engine, keyName)
&& decision.generate();
} }
Set<String> reachable = Set.of(); Set<String> reachable = Set.of();
if (requiresNativeReachability) { if (requiresNativeReachability) {
@@ -190,7 +196,7 @@ public class CommandStructure implements DirectorExecutor {
int errors = 0; int errors = 0;
for (String keyName : structureKeys) { for (String keyName : structureKeys) {
IrisNativeStructureDecision decision = decisions.get(keyName); IrisNativeStructureDecision decision = decisions.get(keyName);
if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { if (IrisStructureLocator.isPlaced(engine, keyName)) {
try { try {
IrisStructureLocator.LocateResult result = IrisStructureLocator.LocateResult result =
IrisStructureLocator.locate(engine, keyName, centerX, centerZ, searchRadius); IrisStructureLocator.locate(engine, keyName, centerX, centerZ, searchRadius);
@@ -205,7 +211,7 @@ public class CommandStructure implements DirectorExecutor {
continue; continue;
} }
located++; located++;
messages.add(C.AQUA + "[iris] " + C.WHITE + keyName + C.GREEN + " @ " messages.add(C.AQUA + "[iris-planned] " + C.WHITE + keyName + C.GREEN + " @ "
+ result.originX() + "," + result.baseY() + "," + result.originZ()); + result.originX() + "," + result.baseY() + "," + result.originZ());
} catch (Throwable error) { } catch (Throwable error) {
errors++; errors++;
@@ -230,7 +236,7 @@ public class CommandStructure implements DirectorExecutor {
nativeEligible++; nativeEligible++;
messages.add(C.GREEN + "[native-eligible] " + C.WHITE + keyName); messages.add(C.GREEN + "[native-eligible] " + C.WHITE + keyName);
} }
messages.add(C.GREEN + "Structure verify: " + C.WHITE + located + C.GREEN + " Iris placements located, " messages.add(C.GREEN + "Structure verify: " + C.WHITE + located + C.GREEN + " Iris placement plans found, "
+ C.WHITE + nativeEligible + C.GREEN + " native structures eligible, " + C.WHITE + nativeEligible + C.GREEN + " native structures eligible, "
+ C.WHITE + disabled + C.GREEN + " disabled by policy, " + C.WHITE + disabled + C.GREEN + " disabled by policy, "
+ C.WHITE + unreachable + C.GREEN + " biome-unreachable, " + C.WHITE + unreachable + C.GREEN + " biome-unreachable, "
@@ -229,29 +229,21 @@ public class CommandStudio implements DirectorExecutor {
} }
} }
@Director(description = "Create a new studio project", descriptionKey = "iris.director.commandstudio.director.create_new_studio_project", aliases = "+", sync = true) @Director(description = "Create a new studio project", descriptionKey = "iris.director.commandstudio.director.create_new_studio_project", aliases = "+")
public void create( public void create(
@Param(description = "The name of this new Iris Project.", descriptionKey = "iris.director.commandstudio.param.name_this_new_iris_project", defaultValue = "studio") @Param(description = "The name of this new Iris Project.", descriptionKey = "iris.director.commandstudio.param.name_this_new_iris_project", defaultValue = "studio")
String name, String name,
@Param( @Param(
description = "Copy the contents of an existing project in your packs folder and use it as a template in this new project.", descriptionKey = "iris.director.commandstudio.param.copy_contents_existing_project_your_packs_folder_use_it_as_template_this", description = "Copy the contents of an existing project in your packs folder and use it as a template in this new project.", descriptionKey = "iris.director.commandstudio.param.copy_contents_existing_project_your_packs_folder_use_it_as_template_this",
defaultValue = "null",
contextual = true, contextual = true,
customHandler = NullableDimensionHandler.class customHandler = NullableDimensionHandler.class
) )
IrisDimension template) { IrisDimension template) {
String projectName = name;
if (name.equals("studio")) {
File workspace = Iris.service(StudioSVC.class).getWorkspaceFolder();
int suffix = 2;
while (new File(workspace, projectName).exists()) {
projectName = "studio" + suffix++;
}
}
if (template != null) { if (template != null) {
Iris.service(StudioSVC.class).create(sender(), projectName, template.getLoadKey()); Iris.service(StudioSVC.class).create(sender(), name, template);
} else { } else {
Iris.service(StudioSVC.class).create(sender(), projectName); Iris.service(StudioSVC.class).create(sender(), name);
} }
} }
@@ -0,0 +1,362 @@
package art.arcane.iris.core;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.lang.reflect.Constructor;
import java.lang.reflect.Proxy;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class BukkitWorldReconcilerTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void loadLeaseCoversRegistrationThroughExactWorldCompletion() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
LifecycleOperationCoordinator coordinator = coordinator();
FakeBackend backend = new FakeBackend();
CompletableFuture<World> creation = new CompletableFuture<>();
backend.creation = creation;
World exactWorld = world(backend.worldKey);
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator);
CompletableFuture<BukkitWorldReconciler.LoadResult> load = reconciler.loadWorld(configuration, backend.worldKey.toString());
assertFalse(load.isDone());
assertEquals(LifecycleOperationCoordinator.OperationKind.WORLD_LOAD,
coordinator.active(LifecycleOperationCoordinator.Domain.WORLD_MUTATION)
.orElseThrow()
.kind());
assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration)
.getString("worlds.probe.generator"));
assertThrows(LifecycleOperationCoordinator.BusyException.class,
() -> coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE,
backend.worldKey.toString()));
backend.loaded = Optional.of(exactWorld);
creation.complete(exactWorld);
BukkitWorldReconciler.LoadResult result = load.join();
assertTrue(result.succeeded());
assertEquals(BukkitWorldReconciler.ReconciliationStatus.LOADED, result.status());
assertEquals(BukkitWorldConfiguration.Registration.CREATED, result.registration());
assertFalse(result.rollbackAttempted());
assertTrue(coordinator.isIdle());
}
@Test
public void failedCreationRollsBackOnlyNewRegistration() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
FakeBackend backend = new FakeBackend();
backend.creation = CompletableFuture.failedFuture(new IllegalStateException("create failed"));
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.CREATE_FAILED, result.status());
assertEquals(BukkitWorldConfiguration.Registration.CREATED, result.registration());
assertTrue(result.rollbackAttempted());
assertTrue(result.rollbackSucceeded());
assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe"));
}
@Test
public void failedCreationPreservesPreexistingMatchingRegistration() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L);
FakeBackend backend = new FakeBackend();
backend.configuredSeed = 1337L;
backend.creation = CompletableFuture.failedFuture(new IllegalStateException("create failed"));
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
assertEquals(BukkitWorldConfiguration.Registration.UNCHANGED, result.registration());
assertFalse(result.rollbackAttempted());
assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration)
.getString("worlds.probe.generator"));
assertEquals(1337L, YamlConfiguration.loadConfiguration(configuration)
.getLong("worlds.probe.seed"));
}
@Test
public void rollbackDoesNotRemoveARegistrationChangedDuringCreation() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
FakeBackend backend = new FakeBackend();
CompletableFuture<World> creation = new CompletableFuture<>();
backend.creation = creation;
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
CompletableFuture<BukkitWorldReconciler.LoadResult> load = reconciler
.loadWorld(configuration, backend.worldKey.toString());
YamlConfiguration changed = YamlConfiguration.loadConfiguration(configuration);
changed.set("worlds.probe.generator", "Other:generator");
changed.save(configuration);
creation.completeExceptionally(new IllegalStateException("create failed"));
BukkitWorldReconciler.LoadResult result = load.join();
assertTrue(result.rollbackAttempted());
assertFalse(result.rollbackSucceeded());
assertEquals("Other:generator", YamlConfiguration.loadConfiguration(configuration)
.getString("worlds.probe.generator"));
}
@Test
public void mismatchedCreatedWorldIsNotReportedAsLoaded() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
FakeBackend backend = new FakeBackend();
backend.creation = CompletableFuture.completedFuture(world(new NamespacedKey("iris", "other")));
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
assertFalse(result.succeeded());
assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_MISMATCH, result.status());
assertTrue(result.rollbackSucceeded());
assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe"));
}
@Test
public void mismatchedResolvedWorldIsNotReportedAsLoaded() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
FakeBackend backend = new FakeBackend();
World exactCreatedWorld = world(backend.worldKey);
backend.creation = CompletableFuture.completedFuture(exactCreatedWorld);
backend.loaded = Optional.of(world(new NamespacedKey("iris", "other")));
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
BukkitWorldReconciler.LoadResult result = reconciler
.loadWorld(configuration, backend.worldKey.toString())
.join();
assertFalse(result.succeeded());
assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_MISMATCH, result.status());
assertTrue(result.rollbackSucceeded());
}
@Test
public void exactNonIrisWorldIsAnIdentityConflict() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
FakeBackend backend = new FakeBackend();
backend.loaded = Optional.of(world(backend.worldKey));
backend.irisWorld = false;
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_CONFLICT, result.status());
assertEquals(0, backend.createCount.get());
assertTrue(result.rollbackSucceeded());
}
@Test
public void unresolvedDimensionAndBusyLifecycleDoNotTouchConfiguration() throws Exception {
File unresolvedConfiguration = temporaryFolder.newFile("unresolved.yml");
FakeBackend unresolvedBackend = new FakeBackend();
unresolvedBackend.dimensionResolution = BukkitWorldReconciler.DimensionResolution.failed(
new IllegalStateException("ambiguous"));
BukkitWorldReconciler unresolved = new BukkitWorldReconciler(unresolvedBackend, coordinator());
BukkitWorldReconciler.LoadResult unresolvedResult = unresolved
.loadWorld(unresolvedConfiguration, unresolvedBackend.worldKey.toString())
.join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.DIMENSION_UNRESOLVED, unresolvedResult.status());
assertEquals(0L, unresolvedConfiguration.length());
File busyConfiguration = temporaryFolder.newFile("busy.yml");
LifecycleOperationCoordinator coordinator = coordinator();
LifecycleOperationCoordinator.Lease removal = coordinator.acquire(
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE,
"iris:other");
FakeBackend busyBackend = new FakeBackend();
BukkitWorldReconciler busy = new BukkitWorldReconciler(busyBackend, coordinator);
try {
BukkitWorldReconciler.LoadResult busyResult = busy.loadWorld(busyConfiguration, busyBackend.worldKey.toString()).join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.BUSY, busyResult.status());
assertEquals(0, busyBackend.createCount.get());
assertEquals(0L, busyConfiguration.length());
} finally {
removal.close();
}
}
@Test
public void pendingRestartRefusesWorldLoadBeforeRegistration() throws Exception {
File configuration = temporaryFolder.newFile("bukkit.yml");
LifecycleOperationCoordinator coordinator = coordinator();
assertTrue(coordinator.quiesceForRestart(() -> {
}));
FakeBackend backend = new FakeBackend();
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator);
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.BUSY, result.status());
assertEquals(LifecycleOperationCoordinator.OperationKind.SERVER_RESTART,
((LifecycleOperationCoordinator.BusyException) result.failure()).operationKind());
assertEquals(0, backend.createCount.get());
assertEquals(0L, configuration.length());
}
@Test
public void terminalCreateTimeoutWinsOverLateWorldCompletion() {
NamespacedKey worldKey = new NamespacedKey("iris", "probe");
CompletableFuture<World> source = new CompletableFuture<>();
AtomicInteger timeoutActions = new AtomicInteger();
CompletableFuture<World> guarded = BukkitWorldReconciler.guardCreateCompletion(
source,
worldKey,
1L,
timeoutActions::incrementAndGet);
CompletionException failure = assertThrows(CompletionException.class, guarded::join);
assertTrue(failure.getCause() instanceof TimeoutException);
assertEquals(1, timeoutActions.get());
source.complete(world(worldKey));
assertTrue(guarded.isCompletedExceptionally());
}
@Test
public void timedOutCreationPreservesNewRegistrationForRestartReconciliation() throws Exception {
File configuration = temporaryFolder.newFile("timeout.yml");
FakeBackend backend = new FakeBackend();
backend.creation = CompletableFuture.failedFuture(new TimeoutException("create timed out"));
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
BukkitWorldReconciler.LoadResult result = reconciler
.loadWorld(configuration, backend.worldKey.toString())
.join();
assertEquals(BukkitWorldReconciler.ReconciliationStatus.RESTART_REQUIRED, result.status());
assertFalse(result.rollbackAttempted());
assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration)
.getString("worlds.probe.generator"));
}
private static LifecycleOperationCoordinator coordinator() throws Exception {
Constructor<LifecycleOperationCoordinator> constructor = LifecycleOperationCoordinator.class
.getDeclaredConstructor();
constructor.setAccessible(true);
return constructor.newInstance();
}
private static World world(NamespacedKey worldKey) {
return (World) Proxy.newProxyInstance(
BukkitWorldReconcilerTest.class.getClassLoader(),
new Class[]{World.class},
(proxy, method, arguments) -> switch (method.getName()) {
case "getKey" -> worldKey;
case "getName" -> worldKey.getKey();
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == arguments[0];
case "toString" -> worldKey.toString();
default -> defaultValue(method.getReturnType());
});
}
private static Object defaultValue(Class<?> type) {
if (!type.isPrimitive()) {
return null;
}
if (type == boolean.class) {
return false;
}
if (type == char.class) {
return '\0';
}
if (type == byte.class) {
return (byte) 0;
}
if (type == short.class) {
return (short) 0;
}
if (type == int.class) {
return 0;
}
if (type == long.class) {
return 0L;
}
if (type == float.class) {
return 0F;
}
if (type == double.class) {
return 0D;
}
throw new IllegalStateException("Unsupported primitive type: " + type);
}
private static final class FakeBackend implements BukkitWorldReconciler.Backend {
private final NamespacedKey worldKey;
private final AtomicInteger createCount;
private CompletableFuture<World> creation;
private Optional<World> loaded;
private boolean irisWorld;
private Long configuredSeed;
private BukkitWorldReconciler.DimensionResolution dimensionResolution;
private FakeBackend() {
worldKey = new NamespacedKey("iris", "probe");
createCount = new AtomicInteger();
creation = CompletableFuture.completedFuture(null);
loaded = Optional.empty();
irisWorld = true;
configuredSeed = null;
dimensionResolution = BukkitWorldReconciler.DimensionResolution.resolved("overworld");
}
@Override
public Map<String, String> configuredWorlds() {
return Map.of("probe", "overworld");
}
@Override
public Long configuredSeed(String worldName) {
return configuredSeed;
}
@Override
public Optional<World> loadedWorld(NamespacedKey requestedWorldKey) {
return worldKey.equals(requestedWorldKey) ? loaded : Optional.empty();
}
@Override
public CompletableFuture<World> createWorld(NamespacedKey requestedWorldKey, String dimension, Long seed) {
createCount.incrementAndGet();
return creation;
}
@Override
public boolean isIrisWorld(World world) {
return irisWorld;
}
@Override
public BukkitWorldReconciler.DimensionResolution resolveDimension(NamespacedKey requestedWorldKey) {
return dimensionResolution;
}
}
}
@@ -0,0 +1,176 @@
package art.arcane.iris.core;
import org.junit.Rule;
import org.junit.Test;
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;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class PendingWorldDeleteQueueTest {
private static final String QUARANTINE_NAME = ".iris-delete-6a4fd7fd-8e75-4f2f-b9fa-523b90c41f45";
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void acceptsOnlyCanonicalManagedNamesAndStrictQuarantines() {
assertEquals("alpha", PendingWorldDeleteQueue.normalizeQueueEntry("alpha", "world"));
assertEquals("alpha", PendingWorldDeleteQueue.normalizeQueueEntry("iris:alpha", "world"));
assertEquals(QUARANTINE_NAME, PendingWorldDeleteQueue.normalizeQueueEntry(QUARANTINE_NAME, "world"));
for (String rejected : List.of(
"world",
"world_nether",
"world_the_end",
"overworld",
"the_nether",
"the_end",
"minecraft:overworld",
"minecraft:the_nether",
"minecraft:the_end",
"Alpha",
"alpha beta",
"../alpha",
"alpha/beta",
".iris-delete-6a4fd7fd-8e75-4f2f-b9fa-523b90c41f4",
".iris-delete-6A4FD7FD-8E75-4F2F-B9FA-523B90C41F45"
)) {
assertNull(rejected, PendingWorldDeleteQueue.normalizeQueueEntry(rejected, "world"));
}
}
@Test
public void loadFiltersUnsafeEntriesAndCanonicalizesDuplicates() throws IOException {
File queueFile = temporaryFolder.newFile("pending-world-deletes.txt");
Files.writeString(
queueFile.toPath(),
String.join("\n", "alpha", "iris:alpha", "world", "../escape", QUARANTINE_NAME),
StandardCharsets.UTF_8
);
LinkedHashMap<String, String> queue = PendingWorldDeleteQueue.loadPendingWorldDeleteMap(queueFile, "world");
assertEquals(List.of("alpha", QUARANTINE_NAME), List.copyOf(queue.values()));
}
@Test
public void queueFileReplacementIsCompleteAndLeavesNoTemporaryFile() throws IOException {
File queueFile = new File(temporaryFolder.getRoot(), "state/pending-world-deletes.txt");
LinkedHashMap<String, String> first = new LinkedHashMap<>();
first.put("alpha", "alpha");
first.put(QUARANTINE_NAME, QUARANTINE_NAME);
PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, first);
assertEquals("alpha\n" + QUARANTINE_NAME + "\n", Files.readString(queueFile.toPath()));
LinkedHashMap<String, String> replacement = new LinkedHashMap<>();
replacement.put("beta", "beta");
PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, replacement);
assertEquals("beta\n", Files.readString(queueFile.toPath()));
try (Stream<Path> files = Files.list(queueFile.toPath().getParent())) {
assertFalse(files.anyMatch(path -> path.getFileName().toString().contains(".tmp-")));
}
}
@Test
public void emptyQueueIsDurablyRepresentedByAnEmptyFile() throws IOException {
File queueFile = new File(temporaryFolder.getRoot(), "pending-world-deletes.txt");
PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, new LinkedHashMap<>());
assertTrue(queueFile.isFile());
assertEquals(0L, queueFile.length());
}
@Test
public void writeFailuresPropagateToTheCaller() throws IOException {
File parentFile = temporaryFolder.newFile("not-a-directory");
File queueFile = new File(parentFile, "pending-world-deletes.txt");
assertThrows(
IOException.class,
() -> PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, new LinkedHashMap<>())
);
}
@Test
public void discoversOnlyDirectNonSymlinkStartupDirectories() throws IOException {
File levelRoot = temporaryFolder.newFolder("world");
Path irisNamespace = levelRoot.toPath().resolve("dimensions/iris");
Files.createDirectories(irisNamespace);
Files.createDirectory(irisNamespace.resolve(QUARANTINE_NAME));
Files.createDirectory(irisNamespace.resolve(".iris-delete-not-a-uuid"));
String transientName = "iris-45ba411e-bf7c-493a-bf41-aa020754990b";
Files.createDirectory(irisNamespace.resolve(transientName + "_nether"));
Path nested = irisNamespace.resolve("ordinary/nested");
Files.createDirectories(nested);
Files.createDirectory(nested.resolve(".iris-delete-f70b8c21-9174-43a2-b7b7-a84fc0b2fe4a"));
Path symlinkTarget = temporaryFolder.newFolder("quarantine-target").toPath();
Files.createSymbolicLink(
irisNamespace.resolve(".iris-delete-b8c7ff2d-2efd-410d-b228-6da5d5a46c36"),
symlinkTarget
);
LinkedHashSet<String> discovered = PendingWorldDeleteQueue.discoverStartupWorldNames(levelRoot);
assertEquals(Set.of(QUARANTINE_NAME, transientName), discovered);
}
@Test
public void refusesSymlinkedIrisNamespace() throws IOException {
File levelRoot = temporaryFolder.newFolder("world");
Path dimensions = levelRoot.toPath().resolve("dimensions");
Files.createDirectories(dimensions);
Path external = temporaryFolder.newFolder("external-iris").toPath();
Files.createSymbolicLink(dimensions.resolve("iris"), external);
assertThrows(
IOException.class,
() -> PendingWorldDeleteQueue.discoverStartupWorldNames(levelRoot)
);
}
@Test
public void quarantineEntriesResolveToOnlyTheirExactDirectory() throws IOException {
File levelRoot = temporaryFolder.newFolder("world");
List<Path> paths = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, QUARANTINE_NAME);
assertEquals(List.of(
levelRoot.toPath().resolve("dimensions/iris").resolve(QUARANTINE_NAME).toAbsolutePath()
), paths);
}
@Test
public void exactLogicalEntriesDoNotExpandIntoDimensionFamilies() throws IOException {
File levelRoot = temporaryFolder.newFolder("world");
List<Path> exact = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, "exact:alpha");
List<Path> family = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, "alpha");
assertEquals(List.of(
levelRoot.toPath().resolve("dimensions/iris/alpha").toAbsolutePath()
), exact);
assertEquals(List.of(
levelRoot.toPath().resolve("dimensions/iris/alpha").toAbsolutePath(),
levelRoot.toPath().resolve("dimensions/iris/alpha_nether").toAbsolutePath(),
levelRoot.toPath().resolve("dimensions/iris/alpha_the_end").toAbsolutePath()
), family);
}
}
@@ -0,0 +1,47 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.core.BukkitWorldReconciler;
import art.arcane.volmlib.util.director.annotations.Param;
import org.junit.Test;
import java.io.File;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class CommandIrisLoadWorldContractTest {
@Test
public void commandUsesTypedAsyncReconciliationBeforeReportingSuccess() throws Exception {
Method reconciliation = BukkitWorldReconciler.class.getDeclaredMethod(
"loadWorld",
File.class,
String.class);
assertEquals(CompletableFuture.class, reconciliation.getReturnType());
Method commandMethod = CommandIris.class.getDeclaredMethod("loadWorld", String.class);
Parameter worldParameter = commandMethod.getParameters()[0];
assertEquals(CommandIris.ManagedWorldNameHandler.class,
worldParameter.getAnnotation(Param.class).customHandler());
String source = Files.readString(Path.of(System.getProperty("iris.commandIrisSource")));
String command = source.substring(
source.indexOf("public void loadWorld("),
source.indexOf("private void reportLoadWorldResult("));
String reporter = source.substring(
source.indexOf("private void reportLoadWorldResult("),
source.indexOf("@Director(description = \"Evacuate an iris world\""));
assertTrue(command.contains("IrisWorldStorage.managedKeyFromName(world)"));
assertTrue(command.contains(".loadWorld(BUKKIT_YML, worldKey.toString())"));
assertTrue(command.contains(".whenComplete((result, failure) ->"));
assertFalse(command.contains("checkForBukkitWorlds"));
assertFalse(command.contains("COMMAND_IRIS_LOADED_SUCCESSFULLY"));
assertTrue(reporter.contains("if (result.succeeded())"));
assertTrue(reporter.contains("COMMAND_IRIS_LOADED_SUCCESSFULLY"));
}
}
@@ -0,0 +1,93 @@
package art.arcane.iris.core.commands;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class CommandIrisMainWorldPromotionTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void existingTopLevelWorldIsRefusedWithoutMerging() throws IOException {
PromotionPaths paths = createPromotionPaths("existing-target");
Files.createDirectories(paths.target());
Files.writeString(paths.target().resolve("sentinel.txt"), "keep");
assertThrows(FileAlreadyExistsException.class, () -> CommandIris.publishMainWorldFiles(
paths.current(),
paths.sourceDimension(),
paths.target()
));
assertEquals("keep", Files.readString(paths.target().resolve("sentinel.txt")));
assertFalse(Files.exists(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
}
@Test
public void uncommittedPromotionRollsBackThePublishedWorld() throws IOException {
PromotionPaths paths = createPromotionPaths("rollback-target");
try (CommandIris.MainWorldPublication publication = CommandIris.publishMainWorldFiles(
paths.current(),
paths.sourceDimension(),
paths.target()
)) {
assertTrue(Files.isRegularFile(paths.target().resolve("data/map.dat")));
assertTrue(Files.isRegularFile(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
}
assertFalse(Files.exists(paths.target()));
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
}
@Test
public void committedPromotionKeepsTheCompleteStagedWorld() throws IOException {
PromotionPaths paths = createPromotionPaths("committed-target");
try (CommandIris.MainWorldPublication publication = CommandIris.publishMainWorldFiles(
paths.current(),
paths.sourceDimension(),
paths.target()
)) {
publication.commit();
}
assertEquals("map", Files.readString(paths.target().resolve("data/map.dat")));
assertEquals("region", Files.readString(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
}
private PromotionPaths createPromotionPaths(String targetName) throws IOException {
Path root = temporaryFolder.newFolder(targetName + "-root").toPath();
Path current = root.resolve("world");
Path sourceDimension = current.resolve("dimensions/iris/" + targetName);
Path target = root.resolve(targetName);
Files.createDirectories(current.resolve("data"));
Files.writeString(current.resolve("data/map.dat"), "map");
Files.createDirectories(sourceDimension.resolve("region"));
Files.writeString(sourceDimension.resolve("region/r.0.0.mca"), "region");
return new PromotionPaths(root, current, sourceDimension, target);
}
private boolean hasPromotionStage(Path root, String targetName) throws IOException {
try (Stream<Path> entries = Files.list(root)) {
return entries.anyMatch(path -> path.getFileName().toString().startsWith("." + targetName + ".promoting-"));
}
}
private record PromotionPaths(Path root, Path current, Path sourceDimension, Path target) {
}
}
@@ -0,0 +1,27 @@
package art.arcane.iris.core.commands;
import art.arcane.volmlib.util.director.annotations.Param;
import org.bukkit.World;
import org.junit.Test;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.Arrays;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
public class CommandIrisRemovalCommandContractTest {
@Test
public void removalAcceptsDiskOnlyNamesAndUsesManagedSuggestions() throws NoSuchMethodException {
Method command = CommandIris.class.getDeclaredMethod("remove", String.class, boolean.class);
Parameter worldParameter = command.getParameters()[0];
Param world = worldParameter.getAnnotation(Param.class);
assertEquals(CommandIris.ManagedWorldNameHandler.class, world.customHandler());
assertFalse(Arrays.stream(CommandIris.class.getDeclaredMethods())
.anyMatch(method -> method.getName().equals("remove")
&& method.getParameterCount() == 2
&& method.getParameterTypes()[0] == World.class));
}
}
@@ -0,0 +1,26 @@
package art.arcane.iris.core.commands;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertTrue;
public class CommandIrisUnloadContractTest {
@Test
public void unloadAwaitsEvacuationAndHasATerminalLifecycleTimeout() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.commandIrisSource")));
String command = source.substring(
source.indexOf("public void unloadWorld("),
source.indexOf("private void reportUnloadResult("));
assertTrue(command.contains("IrisToolbelt.evacuateAsync(world)"));
assertTrue(command.indexOf("IrisToolbelt.evacuateAsync(world)")
< command.indexOf("WorldLifecycleService.get().unloadAsync(world, true)"));
assertTrue(command.contains("guardUnloadCompletion(sequence, terminalTimeout, world.getName())"));
assertTrue(command.contains("ServerConfigurator.restart(\"World unload timed out"));
assertTrue(command.indexOf("ServerConfigurator.restart(\"World unload timed out")
< command.indexOf("guarded.completeExceptionally(timeout)"));
}
}
@@ -0,0 +1,27 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.util.common.director.specialhandlers.NullableDimensionHandler;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import org.junit.Test;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
public class CommandStudioCreationContractTest {
@Test
public void createRunsAsynchronouslyAndTemplateIsOptional() throws NoSuchMethodException {
Method command = CommandStudio.class.getDeclaredMethod("create", String.class, IrisDimension.class);
Director director = command.getAnnotation(Director.class);
Parameter templateParameter = command.getParameters()[1];
Param template = templateParameter.getAnnotation(Param.class);
assertFalse(director.sync());
assertEquals("null", template.defaultValue());
assertEquals(NullableDimensionHandler.class, template.customHandler());
}
}
@@ -25,7 +25,7 @@ public class IrisStructureLocateCommandContractTest {
} }
@Test @Test
public void findRoutesOnlyExplicitNativeReplacementThroughIrisLocate() throws IOException { public void findRoutesRegisteredReplacementThroughPersistedNativeLocate() throws IOException {
String source = Files.readString(Path.of(System.getProperty("iris.commandFindSource"))); String source = Files.readString(Path.of(System.getProperty("iris.commandFindSource")));
int methodStart = source.indexOf("public void structure("); int methodStart = source.indexOf("public void structure(");
int methodEnd = source.indexOf("private static Structure resolveNativeStructure", methodStart); int methodEnd = source.indexOf("private static Structure resolveNativeStructure", methodStart);
@@ -34,16 +34,19 @@ public class IrisStructureLocateCommandContractTest {
int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(", nativeResolution); int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(", nativeResolution);
int replacementCheck = method.indexOf( int replacementCheck = method.indexOf(
"decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution); "decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution);
int replacementLocate = method.indexOf("locateIrisStructure(e, structureKey, commandSender)", replacementCheck);
int genericIrisLookup = method.indexOf( int genericIrisLookup = method.indexOf(
"nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)", replacementLocate); "nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)", replacementCheck);
int replacementLocate = method.indexOf("final boolean replacementLocate = irisReplacement", genericIrisLookup);
int nativeLocate = method.indexOf("targetWorld.locateNearestStructure(", genericIrisLookup); int nativeLocate = method.indexOf("targetWorld.locateNearestStructure(", genericIrisLookup);
assertTrue(nativeResolution >= 0); assertTrue(nativeResolution >= 0);
assertTrue(policyResolution > nativeResolution); assertTrue(policyResolution > nativeResolution);
assertTrue(replacementCheck > policyResolution); assertTrue(replacementCheck > policyResolution);
assertTrue(replacementLocate > replacementCheck); assertTrue(genericIrisLookup > replacementCheck);
assertTrue(genericIrisLookup > replacementLocate); assertTrue(replacementLocate > genericIrisLookup);
assertTrue(nativeLocate > policyResolution); assertTrue(nativeLocate > policyResolution);
assertTrue(method.contains("irisReplacement && !IrisStructureLocator.hasNativePlacement"));
assertTrue(method.contains("!replacementLocate && !explicitNativePlacement"));
assertTrue(method.contains("&& !StructureReachability.isReachable"));
assertTrue(method.contains("decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS")); assertTrue(method.contains("decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS"));
assertFalse(method.contains("NativeStructureLocateCapability")); assertFalse(method.contains("NativeStructureLocateCapability"));
} }
@@ -82,7 +85,8 @@ public class IrisStructureLocateCommandContractTest {
int methodEnd = source.indexOf("private void sendVerificationMessages(", methodStart); int methodEnd = source.indexOf("private void sendVerificationMessages(", methodStart);
String method = source.substring(methodStart, methodEnd); String method = source.substring(methodStart, methodEnd);
int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(engine, keyName, false)"); int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(engine, keyName, false)");
int nativeRequirement = method.indexOf("requiresNativeReachability |= decision.generate()", policyResolution); int nativeRequirement = method.indexOf(
"requiresNativeReachability |= !IrisStructureLocator.isPlaced", policyResolution);
int reachabilityGuard = method.indexOf("if (requiresNativeReachability)", nativeRequirement); int reachabilityGuard = method.indexOf("if (requiresNativeReachability)", nativeRequirement);
int reachabilityLookup = method.indexOf("StructureReachability.reachableKeys(engine)", reachabilityGuard); int reachabilityLookup = method.indexOf("StructureReachability.reachableKeys(engine)", reachabilityGuard);
@@ -10,8 +10,6 @@ import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeManager; import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.biome.FixedBiomeSource; import net.minecraft.world.level.biome.FixedBiomeSource;
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.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.Heightmap;
@@ -22,16 +20,11 @@ import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
final class ForcedStructureChunkGenerator extends ChunkGenerator { final class ForcedStructureChunkGenerator extends ChunkGenerator {
private static final int MIN_SAFE_STRUCTURE_Y = 80;
private static final int VERTICAL_MARGIN = 32;
private final ChunkGenerator delegate; private final ChunkGenerator delegate;
private final int targetY;
ForcedStructureChunkGenerator(ChunkGenerator delegate, Holder<Biome> sourceBiome, int targetY) { ForcedStructureChunkGenerator(ChunkGenerator delegate, Holder<Biome> sourceBiome) {
super(new FixedBiomeSource(sourceBiome)); super(new FixedBiomeSource(sourceBiome));
this.delegate = delegate; this.delegate = delegate;
this.targetY = targetY;
} }
@Override @Override
@@ -72,7 +65,7 @@ final class ForcedStructureChunkGenerator extends ChunkGenerator {
@Override @Override
public int getSeaLevel() { public int getSeaLevel() {
return delegate.getMinY() + 1; return delegate.getSeaLevel();
} }
@Override @Override
@@ -83,21 +76,13 @@ final class ForcedStructureChunkGenerator extends ChunkGenerator {
@Override @Override
public int getBaseHeight(int x, int z, Heightmap.Types type, public int getBaseHeight(int x, int z, Heightmap.Types type,
LevelHeightAccessor heightAccessor, RandomState randomState) { LevelHeightAccessor heightAccessor, RandomState randomState) {
return safeOccupiedY(heightAccessor) + 1; return delegate.getBaseHeight(x, z, type, heightAccessor, randomState);
} }
@Override @Override
public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor heightAccessor, public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor heightAccessor,
RandomState randomState) { RandomState randomState) {
int minY = heightAccessor.getMinY(); return delegate.getBaseColumn(x, z, heightAccessor, randomState);
BlockState[] states = new BlockState[heightAccessor.getHeight()];
for (int index = 0; index < states.length; index++) {
int y = minY + index;
states[index] = (y & 1) == 0
? Blocks.STONE.defaultBlockState()
: Blocks.AIR.defaultBlockState();
}
return new NoiseColumn(minY, states);
} }
@Override @Override
@@ -106,13 +91,4 @@ final class ForcedStructureChunkGenerator extends ChunkGenerator {
delegate.addDebugScreenInfo(result, randomState, feetPos); delegate.addDebugScreenInfo(result, randomState, feetPos);
} }
private int safeOccupiedY(LevelHeightAccessor heightAccessor) {
int minY = heightAccessor.getMinY() + VERTICAL_MARGIN;
int maxY = heightAccessor.getMaxY() - VERTICAL_MARGIN;
if (minY > maxY) {
return heightAccessor.getMinY()
+ Math.max(0, heightAccessor.getHeight() / 2);
}
return Math.max(minY, Math.min(maxY, Math.max(MIN_SAFE_STRUCTURE_Y, targetY)));
}
} }
@@ -4,6 +4,7 @@ import art.arcane.iris.engine.framework.NativeStructureStartPlan;
import art.arcane.iris.engine.object.IrisJigsawConfiguration; import art.arcane.iris.engine.object.IrisJigsawConfiguration;
import art.arcane.iris.engine.object.IrisJigsawHeightmap; import art.arcane.iris.engine.object.IrisJigsawHeightmap;
import art.arcane.iris.engine.object.IrisJigsawLiquidSettings; import art.arcane.iris.engine.object.IrisJigsawLiquidSettings;
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
import net.minecraft.core.Holder; import net.minecraft.core.Holder;
import net.minecraft.core.RegistryAccess; import net.minecraft.core.RegistryAccess;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
@@ -18,6 +19,7 @@ import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeSource; import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.levelgen.RandomState; import net.minecraft.world.level.levelgen.RandomState;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure; import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure;
@@ -43,7 +45,7 @@ public final class NativeStructureFactory {
.orElseThrow(() -> new IllegalStateException("Configured native structure '" .orElseThrow(() -> new IllegalStateException("Configured native structure '"
+ plan.source().getStructure() + "' has no usable generation biome")); + plan.source().getStructure() + "' has no usable generation biome"));
ChunkGenerator forcedGenerator = new ForcedStructureChunkGenerator( ChunkGenerator forcedGenerator = new ForcedStructureChunkGenerator(
context.generator(), sourceBiome, plan.baseY()); context.generator(), sourceBiome);
StructureStart generated = configured.generate( StructureStart generated = configured.generate(
sourceHolder, sourceHolder,
context.levelKey(), context.levelKey(),
@@ -82,15 +84,41 @@ public final class NativeStructureFactory {
if (!positioned.isValid()) { if (!positioned.isValid()) {
return StructureStart.INVALID_START; return StructureStart.INVALID_START;
} }
return NativeStructureReferenceEnvelope.wrap( return NativeStructureReferenceEnvelope.wrapForPublication(
positioned, positioned,
source, source,
references, references,
context.templateManager(), plan.placement().resolvedTerrain(),
plan.placement().resolvedTerrain() plan.source().getStructure()
); );
} }
public static JigsawSourceMetadata sourceMetadata(RegistryAccess registryAccess,
StructureTemplateManager templateManager,
JigsawStructure source) {
CompoundTag sourceTag = encodeJigsaw(registryAccess, source);
return new JigsawSourceMetadata(
sourceDistance(sourceTag, "horizontal"),
horizontalReferenceExpansion(source),
NativeStructureTemplatePoolBounds.sourceHorizontalSpan(
registryAccess, templateManager, source));
}
public static int templatePoolHorizontalSpan(RegistryAccess registryAccess,
StructureTemplateManager templateManager,
String templatePoolKey) {
return NativeStructureTemplatePoolBounds.horizontalSpan(
registryAccess, templateManager, templatePoolKey);
}
public static int jigsawStartPoolHorizontalSpan(RegistryAccess registryAccess,
StructureTemplateManager templateManager,
JigsawStructure source,
String templatePoolKey) {
return NativeStructureTemplatePoolBounds.horizontalSpan(
registryAccess, templateManager, source, templatePoolKey);
}
static Structure configure(RegistryAccess registryAccess, Structure source, static Structure configure(RegistryAccess registryAccess, Structure source,
IrisJigsawConfiguration configuration, IrisJigsawConfiguration configuration,
boolean underground, int baseY) { boolean underground, int baseY) {
@@ -103,10 +131,7 @@ public final class NativeStructureFactory {
return source; return source;
} }
RegistryOps<Tag> registryOps = RegistryOps.create(NbtOps.INSTANCE, registryAccess); RegistryOps<Tag> registryOps = RegistryOps.create(NbtOps.INSTANCE, registryAccess);
Tag encoded = Structure.DIRECT_CODEC.encodeStart(registryOps, sourceJigsaw).getOrThrow(); CompoundTag structureTag = encodeJigsaw(registryOps, sourceJigsaw);
if (!(encoded instanceof CompoundTag structureTag)) {
throw new IllegalStateException("Native jigsaw codec did not produce a compound");
}
CompoundTag configuredTag = structureTag.copy(); CompoundTag configuredTag = structureTag.copy();
if (underground) { if (underground) {
CompoundTag height = new CompoundTag(); CompoundTag height = new CompoundTag();
@@ -123,6 +148,20 @@ public final class NativeStructureFactory {
return configured; return configured;
} }
private static CompoundTag encodeJigsaw(RegistryAccess registryAccess,
JigsawStructure source) {
return encodeJigsaw(RegistryOps.create(NbtOps.INSTANCE, registryAccess), source);
}
private static CompoundTag encodeJigsaw(RegistryOps<Tag> registryOps,
JigsawStructure source) {
Tag encoded = Structure.DIRECT_CODEC.encodeStart(registryOps, source).getOrThrow();
if (!(encoded instanceof CompoundTag structureTag)) {
throw new IllegalStateException("Native jigsaw codec did not produce a compound");
}
return structureTag;
}
private static void applyConfiguration(CompoundTag tag, IrisJigsawConfiguration configuration) { private static void applyConfiguration(CompoundTag tag, IrisJigsawConfiguration configuration) {
if (configuration == null) { if (configuration == null) {
return; return;
@@ -168,7 +207,7 @@ public final class NativeStructureFactory {
tag.put("max_distance_from_center", distance); tag.put("max_distance_from_center", distance);
} }
private static int sourceDistance(CompoundTag tag, String axis) { static int sourceDistance(CompoundTag tag, String axis) {
Tag raw = tag.get("max_distance_from_center"); Tag raw = tag.get("max_distance_from_center");
if (raw instanceof CompoundTag compound) { if (raw instanceof CompoundTag compound) {
return compound.getIntOr(axis, 128); return compound.getIntOr(axis, 128);
@@ -176,6 +215,14 @@ public final class NativeStructureFactory {
return tag.getIntOr("max_distance_from_center", 128); return tag.getIntOr("max_distance_from_center", 128);
} }
static int horizontalReferenceExpansion(Structure structure) {
BoundingBox content = new BoundingBox(0, 0, 0, 0, 0, 0);
BoundingBox adjusted = structure.adjustBoundingBox(content);
int expansion = Math.max(content.minX() - adjusted.minX(), adjusted.maxX() - content.maxX());
expansion = Math.max(expansion, content.minZ() - adjusted.minZ());
return Math.max(0, Math.max(expansion, adjusted.maxZ() - content.maxZ()));
}
private static void applyHeightmap(CompoundTag tag, IrisJigsawHeightmap heightmap) { private static void applyHeightmap(CompoundTag tag, IrisJigsawHeightmap heightmap) {
if (heightmap == null || heightmap == IrisJigsawHeightmap.SOURCE) { if (heightmap == null || heightmap == IrisJigsawHeightmap.SOURCE) {
return; return;
@@ -60,9 +60,6 @@ public final class NativeStructureFoundationBuilder {
private static void markFoundationEnvelope(BitSet envelope, List<StructurePiece> pieces, BoundingBox area, private static void markFoundationEnvelope(BitSet envelope, List<StructurePiece> pieces, BoundingBox area,
int x, int z) { int x, int z) {
for (StructurePiece piece : pieces) { for (StructurePiece piece : pieces) {
if (NativeStructureReferenceEnvelope.isMarker(piece)) {
continue;
}
BoundingBox bounds = piece.getBoundingBox(); BoundingBox bounds = piece.getBoundingBox();
if (x < bounds.minX() || x > bounds.maxX() || z < bounds.minZ() || z > bounds.maxZ()) { if (x < bounds.minX() || x > bounds.maxX() || z < bounds.minZ() || z > bounds.maxZ()) {
continue; continue;
@@ -0,0 +1,351 @@
package art.arcane.iris.nativegen;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.IrisStructureLocator;
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
import art.arcane.iris.spi.IrisLogging;
import net.minecraft.SharedConstants;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.Registries;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.IntTag;
import net.minecraft.nbt.NbtUtils;
import net.minecraft.nbt.StreamTagVisitor;
import net.minecraft.nbt.Tag;
import net.minecraft.nbt.visitors.CollectFields;
import net.minecraft.nbt.visitors.FieldSelector;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.datafix.DataFixTypes;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.storage.SimpleRegionStorage;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
public final class NativeStructureLocatePersistence {
public static final int MAX_SELECTED_CANDIDATE_RETRIES = 64;
private static final int MAX_STORAGE_PROBES = 512;
private static final Method PAPER_LEVEL_TYPE_KEY = paperLevelTypeKey();
private NativeStructureLocatePersistence() {
}
public static Probe probe(ServerLevel level, Structure structure, boolean requireUnreferenced) {
return probe(level, structure, requireUnreferenced, new ProbeBudget(MAX_STORAGE_PROBES));
}
public static ProbeBudget probeBudget() {
return new ProbeBudget(MAX_STORAGE_PROBES);
}
public static Probe probe(ServerLevel level, Structure structure, boolean requireUnreferenced,
ProbeBudget budget) {
return new Probe(level, structure, requireUnreferenced, budget);
}
public static Search search(Engine engine, String structureKey,
int blockX, int blockZ, int radius, Probe probe) {
return new Search(engine, structureKey, blockX, blockZ, radius, probe);
}
public static final class Probe {
private final ServerLevel level;
private final Structure structure;
private final String structureKey;
private final boolean requireUnreferenced;
private final ProbeBudget budget;
private final Map<Long, Boolean> storedDecisions = new HashMap<>();
Probe(ServerLevel level, Structure structure, boolean requireUnreferenced,
ProbeBudget budget) {
this.level = level;
this.structure = structure;
Registry<Structure> registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
Identifier identifier = registry.getKey(structure);
if (identifier == null) {
throw new IllegalStateException("Cannot probe an unregistered native structure");
}
this.structureKey = identifier.toString();
this.requireUnreferenced = requireUnreferenced;
this.budget = budget;
}
public boolean accepts(int chunkX, int chunkZ) {
ChunkAccess loaded = level.getChunkSource().getChunk(
chunkX, chunkZ, ChunkStatus.STRUCTURE_STARTS, false);
if (loaded != null) {
StructureStart start = loaded.getStartForStructure(structure);
return start != null && start.isValid()
&& (!requireUnreferenced || start.canBeReferenced());
}
ChunkPos chunkPos = new ChunkPos(chunkX, chunkZ);
Boolean storedDecision = storedDecisions.get(chunkPos.pack());
if (storedDecision != null) {
return storedDecision;
}
boolean accepted = budget.acceptsStored(
level, chunkPos, structureKey, requireUnreferenced);
storedDecisions.put(chunkPos.pack(), accepted);
return accepted;
}
public StructureStart verifySelected(int chunkX, int chunkZ) {
ChunkAccess chunk = level.getChunk(chunkX, chunkZ, ChunkStatus.STRUCTURE_STARTS);
StructureStart start = chunk.getStartForStructure(structure);
if (start == null || !start.isValid()) {
return null;
}
if (requireUnreferenced) {
if (!start.canBeReferenced()) {
return null;
}
}
return start;
}
private void reference(StructureStart start) {
if (requireUnreferenced) {
level.structureManager().addReference(start);
}
}
}
public static final class ProbeBudget {
private final int maximum;
private final Map<Long, StoredChunkState> storedChunks = new HashMap<>();
private final ChunkStorageScanner scanner;
private final StoredChunkDatafixer datafixer;
private int used;
private boolean scanFailureReported;
private ProbeBudget(int maximum) {
this(maximum, null, ProbeBudget::datafix);
}
ProbeBudget(int maximum, ChunkStorageScanner scanner) {
this(maximum, scanner, ProbeBudget::datafix);
}
ProbeBudget(int maximum, ChunkStorageScanner scanner,
StoredChunkDatafixer datafixer) {
this.maximum = maximum;
this.scanner = scanner;
this.datafixer = Objects.requireNonNull(
datafixer, "Stored chunk datafixer must not be null");
}
private void claim() {
if (used >= maximum) {
throw new IrisStructureLocator.CandidateSearchLimitException();
}
used++;
}
private StoredChunkState storedState(ServerLevel level, ChunkPos chunkPos) {
StoredChunkState cached = storedChunks.get(chunkPos.pack());
if (cached != null) {
return cached;
}
claim();
CollectFields fields = new CollectFields(
new FieldSelector(IntTag.TYPE, "DataVersion"),
new FieldSelector("Level", "Structures", CompoundTag.TYPE, "Starts"),
new FieldSelector("structures", CompoundTag.TYPE, "starts"));
ChunkStorageScanner activeScanner = scanner == null
? level.getChunkSource().chunkMap.chunkScanner()::scanChunk : scanner;
try {
activeScanner.scan(chunkPos, fields).join();
} catch (RuntimeException error) {
if (!scanFailureReported) {
scanFailureReported = true;
IrisLogging.reportError(
"Native structure locate could not scan stored chunk state; candidates will be verified by loading their structure starts.",
error);
}
StoredChunkState unresolved = StoredChunkState.missing();
storedChunks.put(chunkPos.pack(), unresolved);
return unresolved;
}
Tag result = fields.getResult();
StoredChunkState resolved;
try {
resolved = result instanceof CompoundTag storedChunk
? StoredChunkState.parse(datafixer.datafix(level, storedChunk))
: StoredChunkState.missing();
} catch (RuntimeException error) {
if (!scanFailureReported) {
scanFailureReported = true;
IrisLogging.reportError(
"Native structure locate could not datafix stored chunk state; candidates will be verified by loading their structure starts.",
error);
}
resolved = StoredChunkState.missing();
}
storedChunks.put(chunkPos.pack(), resolved);
return resolved;
}
private static CompoundTag datafix(ServerLevel level, CompoundTag storedChunk) {
int dataVersion = NbtUtils.getDataVersion(storedChunk);
int currentDataVersion = SharedConstants.getCurrentVersion().dataVersion().version();
if (dataVersion >= currentDataVersion) {
return storedChunk;
}
if (level == null) {
throw new IllegalStateException("Stored chunk datafix requires an active level");
}
CompoundTag context = chunkDataFixContext(level);
SimpleRegionStorage.injectDatafixingContext(storedChunk, context);
return DataFixTypes.CHUNK.updateToCurrentVersion(
level.getServer().getFixerUpper(), storedChunk, dataVersion);
}
private static CompoundTag chunkDataFixContext(ServerLevel level) {
CompoundTag context = new CompoundTag();
String levelIdentifier = level.dimension().identifier().toString();
if (PAPER_LEVEL_TYPE_KEY == null) {
context.putString("dimension", levelIdentifier);
} else {
context.putString("dimension", paperLevelTypeKey(level).identifier().toString());
context.putString("level_identifier", levelIdentifier);
}
level.getChunkSource().getGenerator().getTypeNameForDataFixer()
.ifPresent(identifier -> context.putString("generator", identifier.toString()));
return context;
}
boolean acceptsStored(ServerLevel level, ChunkPos chunkPos,
String structureKey, boolean requireUnreferenced) {
return storedState(level, chunkPos).accepts(structureKey, requireUnreferenced);
}
int used() {
return used;
}
}
public static final class Search {
private final Engine engine;
private final String structureKey;
private final int blockX;
private final int blockZ;
private final int radius;
private final Probe probe;
private final Set<Long> rejectedChunks = new HashSet<>();
private Search(Engine engine, String structureKey, int blockX, int blockZ,
int radius, Probe probe) {
this.engine = engine;
this.structureKey = structureKey;
this.blockX = blockX;
this.blockZ = blockZ;
this.radius = radius;
this.probe = probe;
}
public IrisStructureLocator.LocateResult predict() {
if (rejectedChunks.size() >= MAX_SELECTED_CANDIDATE_RETRIES) {
return new IrisStructureLocator.LocateResult(
IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, 0, 0, 0);
}
return IrisStructureLocator.locateInPlacementRings(
engine, structureKey, blockX, blockZ, radius,
(chunkX, chunkZ) -> !rejectedChunks.contains(ChunkPos.pack(chunkX, chunkZ))
&& probe.accepts(chunkX, chunkZ));
}
public VerifiedStart verify(IrisStructureLocator.LocateResult result) {
int chunkX = result.originX() >> 4;
int chunkZ = result.originZ() >> 4;
StructureStart start = probe.verifySelected(chunkX, chunkZ);
if (start == null) {
return null;
}
NativeStructureOwnershipRecord ownership = NativeStructureOwnershipRecovery.resolve(
engine, probe.level, structureKey, probe.structure, start);
if (ownership == null) {
return null;
}
return new VerifiedStart(start, ownership);
}
public void reject(IrisStructureLocator.LocateResult result) {
rejectedChunks.add(ChunkPos.pack(result.originX() >> 4, result.originZ() >> 4));
}
public void reference(VerifiedStart verified) {
probe.reference(verified.start());
}
}
private static Method paperLevelTypeKey() {
try {
return ServerLevel.class.getMethod("getTypeKey");
} catch (NoSuchMethodException ignored) {
return null;
}
}
private static ResourceKey<?> paperLevelTypeKey(ServerLevel level) {
try {
Object value = PAPER_LEVEL_TYPE_KEY.invoke(level);
if (value instanceof ResourceKey<?> key) {
return key;
}
throw new IllegalStateException("Paper level type key has an unexpected value");
} catch (IllegalAccessException | InvocationTargetException error) {
throw new IllegalStateException("Cannot read Paper level type key", error);
}
}
public record VerifiedStart(StructureStart start,
NativeStructureOwnershipRecord ownership) {
}
@FunctionalInterface
interface ChunkStorageScanner {
CompletableFuture<Void> scan(ChunkPos chunkPos, StreamTagVisitor visitor);
}
@FunctionalInterface
interface StoredChunkDatafixer {
CompoundTag datafix(ServerLevel level, CompoundTag storedChunk);
}
private record StoredChunkState(boolean stored, boolean startsPresent, CompoundTag starts) {
private static StoredChunkState missing() {
return new StoredChunkState(false, false, new CompoundTag());
}
private static StoredChunkState parse(CompoundTag chunk) {
CompoundTag structures = chunk.getCompoundOrEmpty("structures");
return structures.getCompound("starts")
.map(starts -> new StoredChunkState(true, true, starts))
.orElseGet(() -> new StoredChunkState(true, false, new CompoundTag()));
}
private boolean accepts(String structureKey, boolean requireUnreferenced) {
if (!stored || !startsPresent) {
return true;
}
return starts.getCompound(structureKey).map(start -> {
String id = start.getStringOr("id", "");
return !StructureStart.INVALID_START_ID.equals(id)
&& (!requireUnreferenced || start.getIntOr("references", 0) == 0);
}).orElse(false);
}
}
}
@@ -37,6 +37,19 @@ public final class NativeStructureLocateResults {
<= horizontalDistanceSquared(origin, replacement.getFirst()) ? nativeResult : replacement; <= horizontalDistanceSquared(origin, replacement.getFirst()) ? nativeResult : replacement;
} }
public static <T> Pair<BlockPos, T> selectAndReference(
BlockPos origin,
Pair<BlockPos, T> replacement, Runnable replacementReference,
Pair<BlockPos, T> nativeResult, Runnable nativeReference) {
Pair<BlockPos, T> selected = nearest(origin, replacement, nativeResult);
if (selected == replacement && replacement != null) {
replacementReference.run();
} else if (selected == nativeResult && nativeResult != null) {
nativeReference.run();
}
return selected;
}
private static long horizontalDistanceSquared(BlockPos origin, BlockPos target) { private static long horizontalDistanceSquared(BlockPos origin, BlockPos target) {
long dx = (long) target.getX() - origin.getX(); long dx = (long) target.getX() - origin.getX();
long dz = (long) target.getZ() - origin.getZ(); long dz = (long) target.getZ() - origin.getZ();
@@ -0,0 +1,239 @@
package art.arcane.iris.nativegen;
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner;
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
import art.arcane.iris.engine.framework.StructurePlacementGrid;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure;
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 java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
public final class NativeStructureOwnershipFingerprint {
private static final Comparator<PieceIdentity> PIECE_ORDER = Comparator
.comparing(PieceIdentity::type)
.thenComparingInt(PieceIdentity::minX)
.thenComparingInt(PieceIdentity::minY)
.thenComparingInt(PieceIdentity::minZ)
.thenComparingInt(PieceIdentity::maxX)
.thenComparingInt(PieceIdentity::maxY)
.thenComparingInt(PieceIdentity::maxZ)
.thenComparingInt(PieceIdentity::orientation)
.thenComparingInt(PieceIdentity::generationDepth)
.thenComparing(PieceIdentity::detail);
private NativeStructureOwnershipFingerprint() {
}
public static NativeStructureOwnershipRecord capture(String structureKey,
StructureStart start,
NativeStructureStartPlan plan,
BoundingBox referenceEnvelope) {
StructureStart resolvedStart = requireValid(start);
NativeStructureStartPlan resolvedPlan = Objects.requireNonNull(
plan, "Native structure start plan must not be null");
BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(resolvedStart);
BoundingBox references = Objects.requireNonNull(
referenceEnvelope, "Native structure reference envelope must not be null");
ChunkPos origin = resolvedStart.getChunkPos();
return NativeStructureOwnershipRecord.create(
structureKey,
origin.x(),
origin.z(),
StructurePlacementGrid.placementIdentity(resolvedPlan.placement()),
resolvedPlan.baseY(),
content.minX(),
content.minY(),
content.minZ(),
content.maxX(),
content.maxY(),
content.maxZ(),
locatorY(resolvedStart),
references.minX() >> 4,
references.maxX() >> 4,
references.minZ() >> 4,
references.maxZ() >> 4,
fingerprint(structureKey, resolvedStart),
NativeStructurePlacementPlanner.decisionFor(resolvedPlan)
);
}
public static boolean matches(NativeStructureOwnershipRecord record, StructureStart start) {
if (record == null || start == null || !start.isValid()) {
return false;
}
ChunkPos origin = start.getChunkPos();
if (origin.x() != record.originChunkX()
|| origin.z() != record.originChunkZ()
|| !fingerprint(record.structureKey(), start).equals(record.contentFingerprint())) {
return false;
}
BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(start);
if (content.getXSpan() != (record.contentMaxX() - record.contentMinX()) + 1
|| content.getYSpan() != (record.contentMaxY() - record.contentMinY()) + 1
|| content.getZSpan() != (record.contentMaxZ() - record.contentMinZ()) + 1) {
return false;
}
return start.getStructure() instanceof OceanMonumentStructure
|| content.minX() == record.contentMinX()
&& content.minY() == record.contentMinY()
&& content.minZ() == record.contentMinZ()
&& content.maxX() == record.contentMaxX()
&& content.maxY() == record.contentMaxY()
&& content.maxZ() == record.contentMaxZ();
}
public static String fingerprint(String structureKey, StructureStart start) {
StructureStart resolvedStart = requireValid(start);
MessageDigest digest = sha256();
updateString(digest, Objects.requireNonNull(structureKey,
"Native structure key must not be null").trim().toLowerCase(Locale.ROOT));
updateInt(digest, resolvedStart.getChunkPos().x());
updateInt(digest, resolvedStart.getChunkPos().z());
BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(resolvedStart);
List<PieceIdentity> pieces = contentPieces(resolvedStart, content.minY());
updateInt(digest, pieces.size());
for (PieceIdentity piece : pieces) {
updateString(digest, piece.type());
updateInt(digest, piece.minX());
updateInt(digest, piece.minY());
updateInt(digest, piece.minZ());
updateInt(digest, piece.maxX());
updateInt(digest, piece.maxY());
updateInt(digest, piece.maxZ());
updateInt(digest, piece.orientation());
updateInt(digest, piece.generationDepth());
updateString(digest, piece.detail());
}
return HexFormat.of().formatHex(digest.digest());
}
public static int locatorY(StructureStart start) {
StructureStart resolvedStart = requireValid(start);
if (!resolvedStart.getPieces().isEmpty()) {
return resolvedStart.getPieces().getFirst().getLocatorPosition().getY();
}
throw new IllegalStateException("Native structure contains no locatable content pieces");
}
private static List<PieceIdentity> contentPieces(StructureStart start, int contentMinY) {
List<PieceIdentity> pieces = new ArrayList<>();
for (StructurePiece piece : start.getPieces()) {
BoundingBox bounds = piece.getBoundingBox();
Identifier type = BuiltInRegistries.STRUCTURE_PIECE.getKey(piece.getType());
Direction orientation = piece.getOrientation();
String detail = pieceDetail(piece, contentMinY);
pieces.add(new PieceIdentity(
type == null ? "" : type.toString(),
bounds.minX(), bounds.minY() - contentMinY, bounds.minZ(),
bounds.maxX(), bounds.maxY() - contentMinY, bounds.maxZ(),
orientation == null ? -1 : orientation.get2DDataValue(),
piece.getGenDepth(),
detail
));
}
if (pieces.isEmpty()) {
throw new IllegalStateException("Native structure contains no content pieces");
}
pieces.sort(PIECE_ORDER);
return List.copyOf(pieces);
}
private static String pieceDetail(StructurePiece piece, int contentMinY) {
if (!(piece instanceof PoolElementStructurePiece poolPiece)) {
return "";
}
return poolElementIdentity(poolPiece.getElement()) + "|"
+ poolPiece.getPosition().getX() + ","
+ (poolPiece.getPosition().getY() - contentMinY) + ","
+ poolPiece.getPosition().getZ() + "|"
+ poolPiece.getGroundLevelDelta() + "|"
+ poolPiece.getRotation().getSerializedName();
}
private static String poolElementIdentity(StructurePoolElement element) {
Identifier type = BuiltInRegistries.STRUCTURE_POOL_ELEMENT.getKey(element.getType());
StringBuilder identity = new StringBuilder(element.getClass().getName())
.append('|')
.append(type == null ? "" : type)
.append('|')
.append(element.getProjection().getSerializedName())
.append('|')
.append(element.getGroundLevelDelta());
if (element == EmptyPoolElement.INSTANCE) {
return identity.append("|empty").toString();
}
if (element instanceof SinglePoolElement single) {
try {
return identity.append("|template=")
.append(single.getTemplateLocation()).toString();
} catch (RuntimeException inlineTemplate) {
return identity.append("|runtime-template").toString();
}
}
if (element instanceof ListPoolElement list) {
identity.append("|list[");
for (StructurePoolElement child : list.getElements()) {
String childIdentity = poolElementIdentity(child);
identity.append(childIdentity.length()).append(':').append(childIdentity);
}
return identity.append(']').toString();
}
return identity.append("|bounded-custom").toString();
}
private static StructureStart requireValid(StructureStart start) {
StructureStart resolved = Objects.requireNonNull(
start, "Native structure start must not be null");
if (!resolved.isValid()) {
throw new IllegalArgumentException("Native structure start must be valid");
}
return resolved;
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException error) {
throw new IllegalStateException("SHA-256 is unavailable", error);
}
}
private static void updateString(MessageDigest digest, String value) {
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
updateInt(digest, encoded.length);
digest.update(encoded);
}
private static void updateInt(MessageDigest digest, int value) {
digest.update((byte) (value >>> 24));
digest.update((byte) (value >>> 16));
digest.update((byte) (value >>> 8));
digest.update((byte) value);
}
private record PieceIdentity(String type,
int minX, int minY, int minZ,
int maxX, int maxY, int maxZ,
int orientation, int generationDepth, String detail) {
}
}
@@ -0,0 +1,157 @@
package art.arcane.iris.nativegen;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
import art.arcane.iris.engine.framework.NativeStructureOwnershipStore;
import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner;
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
import art.arcane.iris.engine.framework.StructurePlacementGrid;
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
import art.arcane.iris.engine.object.NativeStructureSuppression;
import net.minecraft.core.Holder;
import net.minecraft.core.Registry;
import net.minecraft.core.registries.Registries;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
import java.util.Locale;
import java.util.Objects;
public final class NativeStructureOwnershipRecovery {
private NativeStructureOwnershipRecovery() {
}
public static NativeStructureOwnershipRecord resolve(
Engine engine, ServerLevel level, String structureKey,
Structure structure, StructureStart start) {
Objects.requireNonNull(engine, "Native structure ownership recovery requires an engine");
ServerLevel activeLevel = Objects.requireNonNull(
level, "Native structure ownership recovery requires a level");
Structure activeStructure = Objects.requireNonNull(
structure, "Native structure ownership recovery requires a structure");
if (start == null || !start.isValid() || start.getStructure() != activeStructure) {
return null;
}
ChunkPos origin = start.getChunkPos();
NativeStructureOwnershipRecord persisted = NativeStructureOwnershipStore.findPersisted(
engine, structureKey, origin.x(), origin.z());
if (persisted != null) {
if (NativeStructureOwnershipFingerprint.matches(persisted, start)) {
return persisted;
}
NativeStructureOwnershipStore.discard(
engine, structureKey, origin.x(), origin.z());
}
NativeStructureStartPlan plan = NativeStructurePlacementPlanner.matchingPlan(
engine, structureKey, origin.x(), origin.z());
if (!matchesPlan(structureKey, origin, plan)) {
return null;
}
Registry<Structure> registry = activeLevel.registryAccess().lookupOrThrow(Registries.STRUCTURE);
Holder<Structure> holder = registry.wrapAsHolder(activeStructure);
ChunkGeneratorStructureState state = activeLevel.getChunkSource().getGeneratorState();
if (naturalStartIsAmbiguous(engine, structureKey, activeStructure, holder, state, plan)) {
return null;
}
StructureStart expected = generateExpected(
engine, activeLevel, holder, plan, start.getReferences());
NativeStructureOwnershipRecord recovered = proveCandidate(
structureKey, activeStructure, start, plan, expected, false);
if (recovered == null) {
return null;
}
NativeStructureOwnershipStore.record(engine, recovered);
return recovered;
}
static NativeStructureOwnershipRecord proveCandidate(
String structureKey, Structure structure, StructureStart persisted,
NativeStructureStartPlan plan, StructureStart expected,
boolean naturalStartAmbiguous) {
if (naturalStartAmbiguous || structure == null
|| persisted == null || !persisted.isValid()
|| expected == null || !expected.isValid()
|| persisted.getStructure() != structure
|| expected.getStructure() != structure
|| !matchesPlan(structureKey, persisted.getChunkPos(), plan)
|| !persisted.getChunkPos().equals(expected.getChunkPos())) {
return null;
}
BoundingBox referenceEnvelope = NativeStructureReferenceEnvelope.referenceBounds(
expected, structure, plan.placement().resolvedTerrain(), structureKey);
NativeStructureOwnershipRecord candidate = NativeStructureOwnershipFingerprint.capture(
structureKey, expected, plan, referenceEnvelope);
if (candidate.placementIdentity()
!= StructurePlacementGrid.placementIdentity(plan.placement())) {
return null;
}
return NativeStructureOwnershipFingerprint.matches(candidate, persisted)
? candidate : null;
}
private static boolean matchesPlan(String structureKey, ChunkPos origin,
NativeStructureStartPlan plan) {
if (structureKey == null || structureKey.isBlank() || plan == null) {
return false;
}
return plan.chunkX() == origin.x()
&& plan.chunkZ() == origin.z()
&& normalize(structureKey).equals(normalize(plan.source().getStructure()));
}
private static StructureStart generateExpected(
Engine engine, ServerLevel level, Holder<Structure> holder,
NativeStructureStartPlan plan, int references) {
ChunkGeneratorStructureState state = level.getChunkSource().getGeneratorState();
ChunkGenerator generator = level.getChunkSource().getGenerator();
BiomeSource biomeSource = generator.getBiomeSource();
NativeStructureFactory.GenerationContext generationContext =
new NativeStructureFactory.GenerationContext(
level.registryAccess(),
generator,
biomeSource,
state.randomState(),
level.getStructureManager(),
state.getLevelSeed(),
level.dimension(),
level,
biome -> true,
generator.getSeaLevel(),
(x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()
);
return NativeStructureFactory.generate(generationContext, holder, plan, references);
}
private static boolean naturalStartIsAmbiguous(
Engine engine, String structureKey, Structure structure,
Holder<Structure> holder, ChunkGeneratorStructureState state,
NativeStructureStartPlan plan) {
if (plan.placement().getNativeSuppression() == NativeStructureSuppression.REPLACE_SOURCE) {
return false;
}
NativeStructureGenerationStatus sourceStatus = NativeStructureGenerationPolicy.resolve(
engine, structureKey,
NativeStructureVegetationClearer.isUndergroundStep(structure.step())).status();
if (sourceStatus == NativeStructureGenerationStatus.REPLACED_BY_IRIS) {
return false;
}
for (StructurePlacement placement : state.getPlacementsForStructure(holder)) {
if (placement.isStructureChunk(state, plan.chunkX(), plan.chunkZ())) {
return true;
}
}
return false;
}
private static String normalize(String structureKey) {
return structureKey == null ? "" : structureKey.trim().toLowerCase(Locale.ROOT);
}
}
@@ -42,9 +42,18 @@ public final class NativeStructurePostProcessor {
if (targets == null || targets.isEmpty()) { if (targets == null || targets.isEmpty()) {
return; return;
} }
NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceTerrain =
NativeStructureTerrainIntegrator.captureSourceTerrain(world, area, targets);
for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) { for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) {
NativeStructureTerrainIntegrator.integrateTerrain(world, area, target.structureId(), target.start(), NativeStructureTerrainIntegrator.integrateTerrain(world, area, target.structureId(), target.start(),
target.terrain(), paletteBlockResolver); target.terrain(), paletteBlockResolver, sourceTerrain);
}
for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) {
if (NativeStructureTerrainIntegrator.clearsLegacyTemplateAir(
target.start(), target.terrain())) {
NativeStructureTerrainIntegrator.clearLegacyTemplateAir(
world, area, target.start(), () -> world.getLevel().getStructureManager());
}
} }
} }
@@ -3,43 +3,61 @@ package art.arcane.iris.nativegen;
import art.arcane.iris.engine.object.IrisStructureTerrain; import art.arcane.iris.engine.object.IrisStructureTerrain;
import art.arcane.iris.engine.object.IrisStructureTerrainMode; import art.arcane.iris.engine.object.IrisStructureTerrainMode;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructurePiece; import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
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.BlockIgnoreProcessor;
import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorList;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public final class NativeStructureReferenceEnvelope { public final class NativeStructureReferenceEnvelope {
private static final int MARKER_GROUND_LEVEL_DELTA = Integer.MIN_VALUE;
private static final int MAX_REFERENCE_DISTANCE_CHUNKS = 8; private static final int MAX_REFERENCE_DISTANCE_CHUNKS = 8;
private static final StructurePoolElement MARKER_ELEMENT = StructurePoolElement.single( private static final Set<String> WARNED_CLIPPED_TERRAIN = ConcurrentHashMap.newKeySet();
"minecraft:empty", private static final Set<String> WARNED_SKIPPED_CONTENT = ConcurrentHashMap.newKeySet();
Holder.direct(new StructureProcessorList(List.of(
BlockIgnoreProcessor.STRUCTURE_AND_AIR))),
LiquidSettings.APPLY_WATERLOGGING
).apply(StructureTemplatePool.Projection.RIGID);
private NativeStructureReferenceEnvelope() { private NativeStructureReferenceEnvelope() {
} }
public static StructureStart wrap(StructureStart generated, Structure source, int references, public static StructureStart wrap(StructureStart generated, Structure source, int references,
StructureTemplateManager templateManager,
IrisStructureTerrain terrain) { IrisStructureTerrain terrain) {
List<StructurePiece> pieces = new ArrayList<>(generated.getPieces()); return wrap(generated, source, references, terrain, null);
}
public static StructureStart wrap(StructureStart generated, Structure source, int references,
IrisStructureTerrain terrain, String structureKey) {
referenceBounds(generated, source, terrain, structureKey);
return new StructureStart(
source,
generated.getChunkPos(),
references,
new PiecesContainer(List.copyOf(generated.getPieces()))
);
}
public static StructureStart wrapForPublication(
StructureStart generated, Structure source, int references,
IrisStructureTerrain terrain, String structureKey) {
try {
return wrap(generated, source, references, terrain, structureKey);
} catch (UnrepresentableContentException exception) {
warnSkippedContent(generated, source, structureKey);
return StructureStart.INVALID_START;
}
}
public static BoundingBox referenceBounds(StructureStart start, Structure source,
IrisStructureTerrain terrain) {
return referenceBounds(start, source, terrain, null);
}
public static BoundingBox referenceBounds(StructureStart start, Structure source,
IrisStructureTerrain terrain,
String structureKey) {
IrisStructureTerrainMode mode = terrain == null IrisStructureTerrainMode mode = terrain == null
? IrisStructureTerrainMode.PRESERVE : terrain.resolvedMode(); ? IrisStructureTerrainMode.PRESERVE : terrain.resolvedMode();
boolean usesEnvelope = mode == IrisStructureTerrainMode.BORE boolean usesEnvelope = mode == IrisStructureTerrainMode.BORE
@@ -47,9 +65,19 @@ public final class NativeStructureReferenceEnvelope {
|| mode == IrisStructureTerrainMode.VACUUM || mode == IrisStructureTerrainMode.VACUUM
|| mode == IrisStructureTerrainMode.ENCASE; || mode == IrisStructureTerrainMode.ENCASE;
int horizontalPadding = usesEnvelope ? Math.max(0, terrain.getHorizontalPadding()) : 0; int horizontalPadding = usesEnvelope ? Math.max(0, terrain.getHorizontalPadding()) : 0;
if (horizontalPadding > 0) { BoundingBox content = contentBounds(start);
BoundingBox content = contentBounds(pieces); if (!fitsReferenceRange(start.getChunkPos(), content)) {
BoundingBox envelope = new BoundingBox( throw new UnrepresentableContentException("Native structure content at "
+ start.getChunkPos().x() + "," + start.getChunkPos().z()
+ " exceeds Minecraft's " + MAX_REFERENCE_DISTANCE_CHUNKS
+ "-chunk structure reference range");
}
BoundingBox adjustedEnvelope;
boolean arithmeticClipped = false;
try {
BoundingBox envelope = horizontalPadding == 0
? content
: new BoundingBox(
Math.subtractExact(content.minX(), horizontalPadding), Math.subtractExact(content.minX(), horizontalPadding),
content.minY(), content.minY(),
Math.subtractExact(content.minZ(), horizontalPadding), Math.subtractExact(content.minZ(), horizontalPadding),
@@ -57,41 +85,36 @@ public final class NativeStructureReferenceEnvelope {
content.maxY(), content.maxY(),
Math.addExact(content.maxZ(), horizontalPadding) Math.addExact(content.maxZ(), horizontalPadding)
); );
BoundingBox referencedEnvelope = clampReferenceRange(generated.getChunkPos(), envelope); adjustedEnvelope = mode == IrisStructureTerrainMode.SOURCE
if (!sameHorizontalBounds(envelope, referencedEnvelope)) { ? source.adjustBoundingBox(content) : envelope;
IrisLogging.warn("Native structure terrain envelope at " adjustedEnvelope = encapsulating(adjustedEnvelope, content);
+ generated.getChunkPos().x() + "," + generated.getChunkPos().z() } catch (ArithmeticException exception) {
+ " was clipped to Minecraft's " + MAX_REFERENCE_DISTANCE_CHUNKS adjustedEnvelope = maximumReferenceBounds(start.getChunkPos(), content);
+ "-chunk structure reference range"); arithmeticClipped = true;
} }
pieces.add(marker(templateManager, referencedEnvelope.minX(), BoundingBox referencedEnvelope = clampReferenceRange(start.getChunkPos(), adjustedEnvelope);
referencedEnvelope.minY(), referencedEnvelope.minZ())); if (arithmeticClipped || !sameHorizontalBounds(adjustedEnvelope, referencedEnvelope)) {
pieces.add(marker(templateManager, referencedEnvelope.maxX(), warnClippedTerrain(start, source, structureKey);
referencedEnvelope.maxY(), referencedEnvelope.maxZ())); return referencedEnvelope;
}
return adjustedEnvelope;
}
public static boolean contentFitsReferenceRange(StructureStart start) {
try {
return fitsReferenceRange(start.getChunkPos(), contentBounds(start));
} catch (UnrepresentableContentException exception) {
return false;
} }
return new StructureStart(
source,
generated.getChunkPos(),
references,
new PiecesContainer(List.copyOf(pieces))
);
} }
public static BoundingBox contentBounds(StructureStart start) { public static BoundingBox contentBounds(StructureStart start) {
return contentBounds(start.getPieces()); return contentBounds(start.getPieces());
} }
public static boolean isMarker(StructurePiece piece) {
return piece instanceof PoolElementStructurePiece poolPiece
&& poolPiece.getGroundLevelDelta() == MARKER_GROUND_LEVEL_DELTA;
}
private static BoundingBox contentBounds(List<StructurePiece> pieces) { private static BoundingBox contentBounds(List<StructurePiece> pieces) {
BoundingBox bounds = null; BoundingBox bounds = null;
for (StructurePiece piece : pieces) { for (StructurePiece piece : pieces) {
if (isMarker(piece)) {
continue;
}
bounds = bounds == null bounds = bounds == null
? copy(piece.getBoundingBox()) ? copy(piece.getBoundingBox())
: bounds.encapsulate(piece.getBoundingBox()); : bounds.encapsulate(piece.getBoundingBox());
@@ -102,25 +125,11 @@ public final class NativeStructureReferenceEnvelope {
return bounds; return bounds;
} }
private static PoolElementStructurePiece marker(StructureTemplateManager templateManager,
int x, int y, int z) {
BlockPos position = new BlockPos(x, y, z);
return new PoolElementStructurePiece(
templateManager,
MARKER_ELEMENT,
position,
MARKER_GROUND_LEVEL_DELTA,
Rotation.NONE,
new BoundingBox(position),
LiquidSettings.APPLY_WATERLOGGING
);
}
private static BoundingBox clampReferenceRange(ChunkPos startChunk, BoundingBox envelope) { private static BoundingBox clampReferenceRange(ChunkPos startChunk, BoundingBox envelope) {
int minX = (startChunk.x() - MAX_REFERENCE_DISTANCE_CHUNKS) << 4; int minX = minimumReferenceBlock(startChunk.x());
int maxX = ((startChunk.x() + MAX_REFERENCE_DISTANCE_CHUNKS) << 4) + 15; int maxX = maximumReferenceBlock(startChunk.x());
int minZ = (startChunk.z() - MAX_REFERENCE_DISTANCE_CHUNKS) << 4; int minZ = minimumReferenceBlock(startChunk.z());
int maxZ = ((startChunk.z() + MAX_REFERENCE_DISTANCE_CHUNKS) << 4) + 15; int maxZ = maximumReferenceBlock(startChunk.z());
return new BoundingBox( return new BoundingBox(
Math.max(envelope.minX(), minX), Math.max(envelope.minX(), minX),
envelope.minY(), envelope.minY(),
@@ -131,6 +140,40 @@ public final class NativeStructureReferenceEnvelope {
); );
} }
private static BoundingBox maximumReferenceBounds(ChunkPos startChunk, BoundingBox content) {
return new BoundingBox(
minimumReferenceBlock(startChunk.x()), content.minY(),
minimumReferenceBlock(startChunk.z()),
maximumReferenceBlock(startChunk.x()), content.maxY(),
maximumReferenceBlock(startChunk.z())
);
}
private static boolean fitsReferenceRange(ChunkPos startChunk, BoundingBox content) {
return content.minX() >= minimumReferenceBlock(startChunk.x())
&& content.maxX() <= maximumReferenceBlock(startChunk.x())
&& content.minZ() >= minimumReferenceBlock(startChunk.z())
&& content.maxZ() <= maximumReferenceBlock(startChunk.z());
}
private static int minimumReferenceBlock(int originChunk) {
return checkedReferenceBlock(
((long) originChunk - MAX_REFERENCE_DISTANCE_CHUNKS) << 4, originChunk);
}
private static int maximumReferenceBlock(int originChunk) {
return checkedReferenceBlock(
(((long) originChunk + MAX_REFERENCE_DISTANCE_CHUNKS) << 4) + 15L, originChunk);
}
private static int checkedReferenceBlock(long block, int originChunk) {
if (block < Integer.MIN_VALUE || block > Integer.MAX_VALUE) {
throw new UnrepresentableContentException("Native structure origin chunk " + originChunk
+ " exceeds Minecraft's block coordinate range");
}
return (int) block;
}
private static boolean sameHorizontalBounds(BoundingBox left, BoundingBox right) { private static boolean sameHorizontalBounds(BoundingBox left, BoundingBox right) {
return left.minX() == right.minX() return left.minX() == right.minX()
&& left.minZ() == right.minZ() && left.minZ() == right.minZ()
@@ -143,4 +186,51 @@ public final class NativeStructureReferenceEnvelope {
bounds.minX(), bounds.minY(), bounds.minZ(), bounds.minX(), bounds.minY(), bounds.minZ(),
bounds.maxX(), bounds.maxY(), bounds.maxZ()); bounds.maxX(), bounds.maxY(), bounds.maxZ());
} }
private static BoundingBox encapsulating(BoundingBox left, BoundingBox right) {
return new BoundingBox(
Math.min(left.minX(), right.minX()),
Math.min(left.minY(), right.minY()),
Math.min(left.minZ(), right.minZ()),
Math.max(left.maxX(), right.maxX()),
Math.max(left.maxY(), right.maxY()),
Math.max(left.maxZ(), right.maxZ())
);
}
private static void warnClippedTerrain(
StructureStart start, Structure source, String structureKey) {
String key = warningKey(source, structureKey);
if (WARNED_CLIPPED_TERRAIN.add(key)) {
IrisLogging.warn("Clipping optional terrain envelope for native structure '"
+ key + "' at " + start.getChunkPos().x() + "," + start.getChunkPos().z()
+ " to Minecraft's " + MAX_REFERENCE_DISTANCE_CHUNKS
+ "-chunk reference range; all generated content remains exactly referenced");
}
}
private static void warnSkippedContent(
StructureStart start, Structure source, String structureKey) {
String key = warningKey(source, structureKey);
if (WARNED_SKIPPED_CONTENT.add(key)) {
IrisLogging.warn("Skipping native structure '" + key + "' at "
+ start.getChunkPos().x() + "," + start.getChunkPos().z()
+ " because its generated content exceeds Minecraft's "
+ MAX_REFERENCE_DISTANCE_CHUNKS
+ "-chunk reference range; no structure start, ownership, references, or blocks were published");
}
}
private static String warningKey(Structure source, String structureKey) {
if (structureKey != null && !structureKey.isBlank()) {
return structureKey.trim().toLowerCase(Locale.ROOT);
}
return source.getClass().getName();
}
public static final class UnrepresentableContentException extends IllegalStateException {
private UnrepresentableContentException(String message) {
super(message);
}
}
} }
@@ -0,0 +1,224 @@
package art.arcane.iris.nativegen;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
import art.arcane.iris.engine.framework.NativeStructureOwnershipStore;
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
import art.arcane.iris.spi.IrisLogging;
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
import it.unimi.dsi.fastutil.longs.LongSet;
import net.minecraft.core.Registry;
import net.minecraft.core.SectionPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.status.ChunkStatus;
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 java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public final class NativeStructureReferenceRepair {
private static final int REFERENCE_DISTANCE_CHUNKS =
NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS;
private static final Set<String> WARNED_POLICY_INVALIDATIONS = ConcurrentHashMap.newKeySet();
private NativeStructureReferenceRepair() {
}
public static void createReferences(Engine engine, WorldGenLevel level,
StructureManager structureManager, ChunkAccess targetChunk) {
ChunkPos target = targetChunk.getPos();
Registry<Structure> registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
ServerLevel serverLevel = level.getLevel();
List<ScannedStart> starts = new ArrayList<>();
for (int originChunkX = target.x() - REFERENCE_DISTANCE_CHUNKS;
originChunkX <= target.x() + REFERENCE_DISTANCE_CHUNKS; originChunkX++) {
for (int originChunkZ = target.z() - REFERENCE_DISTANCE_CHUNKS;
originChunkZ <= target.z() + REFERENCE_DISTANCE_CHUNKS; originChunkZ++) {
ChunkAccess originChunk = level.getChunk(
originChunkX, originChunkZ, ChunkStatus.STRUCTURE_STARTS);
for (Map.Entry<Structure, StructureStart> entry : originChunk.getAllStarts().entrySet()) {
ScannedStart start = scanStart(
engine, serverLevel, structureManager, targetChunk, registry,
originChunk, entry.getKey(), entry.getValue());
if (start != null && isTargetRelevant(engine, targetChunk, start)) {
starts.add(start);
}
}
}
}
for (ScannedStart start : starts) {
boolean valid;
synchronized (start.originChunk()) {
StructureStart current = start.originChunk().getStartForStructure(start.structure());
valid = current == start.start() && current.isValid();
}
if (!valid) {
continue;
}
structureManager.addReferenceForStructure(
SectionPos.bottomOf(targetChunk), start.structure(),
start.origin().pack(), targetChunk);
}
}
private static boolean isTargetRelevant(
Engine engine, ChunkAccess targetChunk, ScannedStart scanned) {
StructureStart start = scanned.start();
Structure structure = scanned.structure();
ChunkPos target = targetChunk.getPos();
if (scanned.ownership() != null) {
return requiresReference(target, scanned.structureKey(), start, scanned.ownership());
}
if (scanned.registered()) {
return requiresNaturalReference(
engine, target, scanned.structureKey(), structure, start);
}
return NativeStructureReferenceEnvelope.contentBounds(start).intersects(
target.getMinBlockX(), target.getMinBlockZ(),
target.getMaxBlockX(), target.getMaxBlockZ());
}
private static ScannedStart scanStart(
Engine engine, ServerLevel level, StructureManager structureManager,
ChunkAccess targetChunk, Registry<Structure> registry,
ChunkAccess originChunk, Structure structure, StructureStart start) {
Identifier identifier = registry.getKey(structure);
String structureKey = identifier == null
? structure.getClass().getName() : identifier.toString();
if (start == null || !start.isValid()) {
return null;
}
if (!NativeStructureReferenceEnvelope.contentFitsReferenceRange(start)) {
if (identifier != null) {
NativeStructureOwnershipStore.discard(
engine, structureKey, start.getChunkPos().x(), start.getChunkPos().z());
}
invalidateStart(structureManager, originChunk, structure, start, targetChunk);
return null;
}
ChunkPos startOrigin = start.getChunkPos();
NativeStructureOwnershipRecord ownership = identifier == null ? null
: NativeStructureOwnershipRecovery.resolve(
engine, level, structureKey, structure, start);
if (ownership != null) {
return new ScannedStart(
originChunk, structure, start, structureKey, ownership, true);
}
if (identifier != null && !naturalPolicyAllows(engine, structureKey, structure)) {
invalidateStart(structureManager, originChunk, structure, start, targetChunk);
if (WARNED_POLICY_INVALIDATIONS.add(structureKey)) {
IrisLogging.warn("Invalidating persisted natural structure start '"
+ structureKey + "' because the current Iris dimension policy disables it");
}
return null;
}
return new ScannedStart(
originChunk, structure, start, structureKey, null, identifier != null);
}
private static boolean naturalPolicyAllows(
Engine engine, String structureKey, Structure structure) {
IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(
engine, structureKey,
NativeStructureVegetationClearer.isUndergroundStep(structure.step()));
return naturalDecisionAllows(decision);
}
static boolean naturalDecisionAllows(IrisNativeStructureDecision decision) {
return Objects.requireNonNull(
decision, "Native structure decision must not be null").generate();
}
private static void invalidateStart(
StructureManager structureManager, ChunkAccess originChunk,
Structure structure, StructureStart start, ChunkAccess targetChunk) {
synchronized (originChunk) {
StructureStart current = originChunk.getStartForStructure(structure);
if (current == start && current.isValid()) {
structureManager.setStartForStructure(
SectionPos.bottomOf(originChunk), structure,
StructureStart.INVALID_START, originChunk);
}
}
if (targetChunk != null) {
removeTargetReference(targetChunk, structure, start.getChunkPos().pack());
}
}
private static void removeTargetReference(
ChunkAccess targetChunk, Structure structure, long origin) {
LongSet current = targetChunk.getAllReferences().get(structure);
if (current == null || !current.contains(origin)) {
return;
}
Map<Structure, LongSet> updated = new HashMap<>(targetChunk.getAllReferences());
LongSet retained = new LongOpenHashSet(current);
retained.remove(origin);
if (retained.isEmpty()) {
updated.remove(structure);
} else {
updated.put(structure, retained);
}
targetChunk.setAllReferences(updated);
}
static boolean requiresNaturalReference(Engine engine, ChunkPos target, String structureKey,
Structure structure, StructureStart start) {
if (target == null || start == null || !start.isValid()) {
return false;
}
IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(
engine, structureKey,
NativeStructureVegetationClearer.isUndergroundStep(structure.step()));
if (!decision.generate()) {
return false;
}
BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds(
start, structure,
NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain()),
structureKey);
return referenceBounds.intersects(
target.getMinBlockX(), target.getMinBlockZ(),
target.getMaxBlockX(), target.getMaxBlockZ());
}
static boolean requiresReference(ChunkPos target, String structureKey,
StructureStart start,
NativeStructureOwnershipRecord ownership) {
if (target == null || start == null || !start.isValid()
|| ownership == null || !ownership.structureKey().equals(structureKey)
|| !ownership.covers(target.x(), target.z())) {
return false;
}
return NativeStructureOwnershipFingerprint.matches(ownership, start);
}
private record ScannedStart(
ChunkAccess originChunk,
Structure structure,
StructureStart start,
String structureKey,
NativeStructureOwnershipRecord ownership,
boolean registered
) {
private ChunkPos origin() {
return ownership == null
? start.getChunkPos()
: new ChunkPos(ownership.originChunkX(), ownership.originChunkZ());
}
}
}
@@ -1,9 +1,14 @@
package art.arcane.iris.nativegen; package art.arcane.iris.nativegen;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
import art.arcane.iris.engine.framework.NativeStructureOwnershipStore;
import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner; import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner;
import art.arcane.iris.engine.framework.NativeStructureStartPlan; import art.arcane.iris.engine.framework.NativeStructureStartPlan;
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
import art.arcane.iris.engine.object.NativeStructureSuppression; import art.arcane.iris.engine.object.NativeStructureSuppression;
import art.arcane.iris.spi.IrisLogging;
import net.minecraft.core.Holder; import net.minecraft.core.Holder;
import net.minecraft.core.Registry; import net.minecraft.core.Registry;
import net.minecraft.core.RegistryAccess; import net.minecraft.core.RegistryAccess;
@@ -17,6 +22,7 @@ import net.minecraft.world.level.biome.BiomeSource;
import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.chunk.ChunkGenerator;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
@@ -24,8 +30,12 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public final class NativeStructureStartInjector { public final class NativeStructureStartInjector {
private static final Set<String> WARNED_DUPLICATE_STRUCTURES = ConcurrentHashMap.newKeySet();
private NativeStructureStartInjector() { private NativeStructureStartInjector() {
} }
@@ -48,14 +58,22 @@ public final class NativeStructureStartInjector {
} }
Holder<Structure> holder = registry.wrapAsHolder(structure); Holder<Structure> holder = registry.wrapAsHolder(structure);
if (configuredStarts.containsKey(structure)) { if (configuredStarts.containsKey(structure)) {
throw new IllegalStateException("Multiple configured native structure placements selected '" if (WARNED_DUPLICATE_STRUCTURES.add(identifier.toString())) {
+ identifier + "' in chunk " + chunk.getPos().x() + "," + chunk.getPos().z() IrisLogging.warn("Ignoring duplicate native structure placements for '"
+ "; Minecraft can persist only one start per registered structure per chunk"); + identifier + "'; the first deterministic candidate owns each chunk start");
}
continue;
} }
StructureStart existing = context.structureManager().getStartForStructure( StructureStart existing = context.structureManager().getStartForStructure(
section, structure, chunk); section, structure, chunk);
boolean replacement = plan.placement().getNativeSuppression() boolean replacement = plan.placement().getNativeSuppression()
== NativeStructureSuppression.REPLACE_SOURCE; == NativeStructureSuppression.REPLACE_SOURCE;
if (!replacement && existing != null && existing.isValid()) {
NativeStructureGenerationStatus sourceStatus = NativeStructureGenerationPolicy.resolve(
context.engine(), identifier.toString(),
NativeStructureVegetationClearer.isUndergroundStep(structure.step())).status();
replacement = sourceStatus == NativeStructureGenerationStatus.REPLACED_BY_IRIS;
}
if (!replacement && existing != null && existing.isValid()) { if (!replacement && existing != null && existing.isValid()) {
continue; continue;
} }
@@ -77,18 +95,43 @@ public final class NativeStructureStartInjector {
); );
StructureStart generated = NativeStructureFactory.generate( StructureStart generated = NativeStructureFactory.generate(
generationContext, holder, plan, references); generationContext, holder, plan, references);
if (!generated.isValid()) { if (!isUsableGeneratedStart(generated)) {
throw new IllegalStateException("Configured native structure '" + identifier if (replacement) {
+ "' produced no valid start in chunk " + chunk.getPos().x() context.structureManager().setStartForStructure(
+ "," + chunk.getPos().z()); section, structure, StructureStart.INVALID_START, chunk);
}
NativeStructureOwnershipStore.discard(
context.engine(), identifier.toString(),
chunk.getPos().x(), chunk.getPos().z());
continue;
}
BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds(
generated, structure, plan.placement().resolvedTerrain(), identifier.toString());
NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture(
identifier.toString(), generated, plan, referenceBounds);
NativeStructureOwnershipStore.record(context.engine(), ownership);
try {
context.structureManager().setStartForStructure(
section, structure, generated, chunk);
} catch (RuntimeException | Error publicationError) {
try {
NativeStructureOwnershipStore.discard(
context.engine(), identifier.toString(),
chunk.getPos().x(), chunk.getPos().z());
} catch (RuntimeException | Error cleanupError) {
publicationError.addSuppressed(cleanupError);
}
throw publicationError;
} }
context.structureManager().setStartForStructure(
section, structure, generated, chunk);
configuredStarts.put(structure, plan); configuredStarts.put(structure, plan);
} }
return Map.copyOf(configuredStarts); return Map.copyOf(configuredStarts);
} }
static boolean isUsableGeneratedStart(StructureStart start) {
return start != null && start.isValid();
}
public record InjectionContext( public record InjectionContext(
Engine engine, Engine engine,
RegistryAccess registryAccess, RegistryAccess registryAccess,
@@ -1,6 +1,8 @@
package art.arcane.iris.nativegen; package art.arcane.iris.nativegen;
import art.arcane.iris.engine.object.IrisObjectVacuum; import art.arcane.iris.engine.object.IrisObjectVacuum;
import art.arcane.iris.engine.object.IrisStructureTerrain;
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
@@ -13,13 +15,11 @@ import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; import net.minecraft.world.level.levelgen.structure.TerrainAdjustment;
import net.minecraft.world.level.levelgen.structure.pools.JigsawJunction; import net.minecraft.world.level.levelgen.structure.pools.JigsawJunction;
import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool; import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.function.IntBinaryOperator; import java.util.function.IntBinaryOperator;
import java.util.function.Supplier;
public final class NativeStructureSurfaceFitter { public final class NativeStructureSurfaceFitter {
private static final double SURFACE_TERRAIN_FALLOFF = 2.0; private static final double SURFACE_TERRAIN_FALLOFF = 2.0;
@@ -30,32 +30,31 @@ public final class NativeStructureSurfaceFitter {
} }
public static void prepareSurfaceStructures(WorldGenLevel world, BoundingBox area, public static void prepareSurfaceStructures(WorldGenLevel world, BoundingBox area,
List<StructureStart> starts, List<NativeStructureTerrainIntegrator.TerrainTarget> targets,
IntBinaryOperator surfaceHeight) { IntBinaryOperator surfaceHeight) {
if (starts == null || starts.isEmpty()) { if (targets == null || targets.isEmpty()) {
return; return;
} }
Objects.requireNonNull(surfaceHeight, "Surface structure terrain fitting requires an Iris height resolver"); Objects.requireNonNull(surfaceHeight, "Surface structure terrain fitting requires an Iris height resolver");
List<SurfaceAnchor> anchors = collectSurfaceAnchors(starts); List<SurfaceAnchor> anchors = collectSurfaceAnchors(targets);
if (!anchors.isEmpty()) { if (!anchors.isEmpty()) {
fitSurfaceTerrain(world, area, anchors, surfaceHeight); fitSurfaceTerrain(world, area, anchors, surfaceHeight);
} }
Supplier<StructureTemplateManager> templates = () -> world.getLevel().getStructureManager();
for (StructureStart start : starts) {
if (requiresSurfaceTerrain(start)) {
NativeStructureTerrainIntegrator.clearLegacyTemplateAir(world, area, start, templates);
}
}
} }
static boolean shouldPrepareSurfaceTerrain(TerrainAdjustment adjustment, static boolean shouldPrepareSurfaceTerrain(TerrainAdjustment adjustment,
GenerationStep.Decoration step) { GenerationStep.Decoration step) {
return adjustment == TerrainAdjustment.BEARD_THIN return adjustment == TerrainAdjustment.BEARD_THIN
&& step == GenerationStep.Decoration.SURFACE_STRUCTURES; || adjustment == TerrainAdjustment.BEARD_BOX;
} }
static int resolveSurfaceTarget(List<SurfaceAnchor> anchors, int worldX, int worldZ, static int resolveSurfaceTarget(List<SurfaceAnchor> anchors, int worldX, int worldZ,
int originalY) { int originalY) {
return resolveSurface(anchors, worldX, worldZ, originalY).targetY();
}
private static SurfaceResolution resolveSurface(List<SurfaceAnchor> anchors,
int worldX, int worldZ, int originalY) {
int localTargetY = originalY; int localTargetY = originalY;
SurfaceAnchor selectedLocal = null; SurfaceAnchor selectedLocal = null;
long totalInfluence = 0L; long totalInfluence = 0L;
@@ -64,11 +63,42 @@ public final class NativeStructureSurfaceFitter {
for (SurfaceAnchor anchor : anchors) { for (SurfaceAnchor anchor : anchors) {
int outX = IrisObjectVacuum.outset(worldX, anchor.minX(), anchor.maxX()); int outX = IrisObjectVacuum.outset(worldX, anchor.minX(), anchor.maxX());
int outZ = IrisObjectVacuum.outset(worldZ, anchor.minZ(), anchor.maxZ()); int outZ = IrisObjectVacuum.outset(worldZ, anchor.minZ(), anchor.maxZ());
long distanceSquared = (long) outX * outX + (long) outZ * outZ; boolean containsColumn = outX == 0 && outZ == 0;
if (distanceSquared > (long) SURFACE_TERRAIN_RADIUS * SURFACE_TERRAIN_RADIUS) { if (containsColumn && anchor.strength() > 1 && originalY < anchor.meetY()) {
if (precedes(anchor, selectedLocal)) {
localTargetY = anchor.meetY();
selectedLocal = anchor;
}
continue;
}
int verticalDistance = anchor.verticalDistance(originalY);
long horizontalDistanceSquared = (long) outX * outX + (long) outZ * outZ;
long distanceSquared = (long) outX * outX + (long) outZ * outZ
+ (long) verticalDistance * verticalDistance;
double factor = 0D;
long radiusSquared = (long) SURFACE_TERRAIN_RADIUS * SURFACE_TERRAIN_RADIUS;
if (distanceSquared <= radiusSquared) {
double distance = Math.sqrt(distanceSquared);
factor = Math.pow(
1D - distance / SURFACE_TERRAIN_RADIUS, SURFACE_TERRAIN_FALLOFF);
}
if (anchor.strength() > 1 && originalY < anchor.meetY()
&& verticalDistance > SURFACE_TERRAIN_RADIUS
&& horizontalDistanceSquared <= radiusSquared) {
double horizontalDistance = Math.sqrt(horizontalDistanceSquared);
double horizontalFactor = Math.pow(
1D - horizontalDistance / SURFACE_TERRAIN_RADIUS,
SURFACE_TERRAIN_FALLOFF);
double rescueProgress = Math.min(1D,
(verticalDistance - SURFACE_TERRAIN_RADIUS)
/ (double) SURFACE_TERRAIN_RADIUS);
double rescueWeight = rescueProgress * rescueProgress
* (3D - 2D * rescueProgress);
factor = Math.max(factor, horizontalFactor * rescueWeight);
}
if (factor <= 0D) {
continue; continue;
} }
boolean containsColumn = outX == 0 && outZ == 0;
if (containsColumn) { if (containsColumn) {
if (precedes(anchor, selectedLocal)) { if (precedes(anchor, selectedLocal)) {
localTargetY = anchor.meetY(); localTargetY = anchor.meetY();
@@ -76,10 +106,6 @@ public final class NativeStructureSurfaceFitter {
} }
continue; continue;
} }
double factor = IrisObjectVacuum.columnInfluence(
worldX, worldZ,
anchor.minX(), anchor.maxX(), anchor.minZ(), anchor.maxZ(),
SURFACE_TERRAIN_RADIUS, SURFACE_TERRAIN_FALLOFF);
long influence = Math.round(factor * SURFACE_TERRAIN_INFLUENCE_SCALE); long influence = Math.round(factor * SURFACE_TERRAIN_INFLUENCE_SCALE);
if (influence <= 0L) { if (influence <= 0L) {
continue; continue;
@@ -90,14 +116,15 @@ public final class NativeStructureSurfaceFitter {
maximumInfluence = Math.max(maximumInfluence, influence); maximumInfluence = Math.max(maximumInfluence, influence);
} }
if (selectedLocal != null) { if (selectedLocal != null) {
return localTargetY; return new SurfaceResolution(localTargetY, selectedLocal.strength() > 1);
} }
if (totalInfluence == 0L) { if (totalInfluence == 0L) {
return originalY; return new SurfaceResolution(originalY, false);
} }
double blendedMeetY = weightedMeetY / (double) totalInfluence; double blendedMeetY = weightedMeetY / (double) totalInfluence;
double factor = maximumInfluence / (double) SURFACE_TERRAIN_INFLUENCE_SCALE; double factor = maximumInfluence / (double) SURFACE_TERRAIN_INFLUENCE_SCALE;
return (int) Math.round(originalY + ((blendedMeetY - originalY) * factor)); return new SurfaceResolution(
(int) Math.round(originalY + ((blendedMeetY - originalY) * factor)), false);
} }
private static boolean precedes(SurfaceAnchor candidate, SurfaceAnchor selected) { private static boolean precedes(SurfaceAnchor candidate, SurfaceAnchor selected) {
@@ -125,41 +152,62 @@ public final class NativeStructureSurfaceFitter {
return false; return false;
} }
private static List<SurfaceAnchor> collectSurfaceAnchors(List<StructureStart> starts) { private static List<SurfaceAnchor> collectSurfaceAnchors(
List<NativeStructureTerrainIntegrator.TerrainTarget> targets) {
List<SurfaceAnchor> anchors = new ArrayList<>(); List<SurfaceAnchor> anchors = new ArrayList<>();
for (StructureStart start : starts) { for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) {
if (!requiresSurfaceTerrain(start)) { if (!requiresSurfaceTerrain(target)) {
continue; continue;
} }
StructureStart start = target.start();
TerrainAdjustment adjustment = start.getStructure().terrainAdaptation();
for (StructurePiece piece : start.getPieces()) { for (StructurePiece piece : start.getPieces()) {
if (NativeStructureReferenceEnvelope.isMarker(piece)) {
continue;
}
if (piece instanceof PoolElementStructurePiece poolPiece) { if (piece instanceof PoolElementStructurePiece poolPiece) {
if (poolPiece.getElement().getProjection() == StructureTemplatePool.Projection.RIGID) { if (poolPiece.getElement().getProjection() == StructureTemplatePool.Projection.RIGID) {
BoundingBox bounds = poolPiece.getBoundingBox(); BoundingBox bounds = poolPiece.getBoundingBox();
anchors.add(new SurfaceAnchor( anchors.add(surfaceAnchor(
bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(), bounds, bounds.minY() + poolPiece.getGroundLevelDelta(),
bounds.minY() + poolPiece.getGroundLevelDelta() - 1, 2)); 2, adjustment));
} }
for (JigsawJunction junction : poolPiece.getJunctions()) { for (JigsawJunction junction : poolPiece.getJunctions()) {
anchors.add(new SurfaceAnchor( anchors.add(new SurfaceAnchor(
junction.getSourceX(), junction.getSourceX(), junction.getSourceX(), junction.getSourceX(),
junction.getSourceZ(), junction.getSourceZ(), junction.getSourceZ(), junction.getSourceZ(),
junction.getSourceGroundY() - 1, 1)); junction.getSourceGroundY() - 1, 1,
junction.getSourceGroundY(), junction.getSourceGroundY()));
} }
continue; continue;
} }
BoundingBox bounds = piece.getBoundingBox(); BoundingBox bounds = piece.getBoundingBox();
anchors.add(new SurfaceAnchor( anchors.add(surfaceAnchor(bounds, bounds.minY(), 2, adjustment));
bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(),
bounds.minY() - 1, 2));
} }
} }
return List.copyOf(anchors); return List.copyOf(anchors);
} }
static SurfaceAnchor surfaceAnchor(BoundingBox bounds, int groundY, int strength,
TerrainAdjustment adjustment) {
int meetY = groundY - 1;
if (adjustment == TerrainAdjustment.BEARD_BOX) {
return new SurfaceAnchor(
bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(),
meetY, strength, groundY, bounds.maxY());
}
return new SurfaceAnchor(bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(),
meetY, strength, groundY, groundY);
}
static boolean requiresSurfaceTerrain(StructureStart start) { static boolean requiresSurfaceTerrain(StructureStart start) {
return requiresSurfaceTerrain(new NativeStructureTerrainIntegrator.TerrainTarget(
null, start, new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE)));
}
static boolean requiresSurfaceTerrain(NativeStructureTerrainIntegrator.TerrainTarget target) {
if (target == null || target.terrain() == null
|| target.terrain().resolvedMode() != IrisStructureTerrainMode.SOURCE) {
return false;
}
StructureStart start = target.start();
return start != null return start != null
&& start.isValid() && start.isValid()
&& shouldPrepareSurfaceTerrain( && shouldPrepareSurfaceTerrain(
@@ -173,14 +221,17 @@ public final class NativeStructureSurfaceFitter {
int depth = area.getZSpan(); int depth = area.getZSpan();
int[] originalHeights = new int[width * depth]; int[] originalHeights = new int[width * depth];
int[] targetHeights = new int[width * depth]; int[] targetHeights = new int[width * depth];
boolean[] rigidBaseSupport = new boolean[width * depth];
for (int z = area.minZ(); z <= area.maxZ(); z++) { for (int z = area.minZ(); z <= area.maxZ(); z++) {
for (int x = area.minX(); x <= area.maxX(); x++) { for (int x = area.minX(); x <= area.maxX(); x++) {
int column = (z - area.minZ()) * width + x - area.minX(); int column = (z - area.minZ()) * width + x - area.minX();
int originalY = Math.max(area.minY(), Math.min( int originalY = Math.max(area.minY(), Math.min(
area.maxY(), surfaceHeight.applyAsInt(x, z))); area.maxY(), surfaceHeight.applyAsInt(x, z)));
SurfaceResolution resolution = resolveSurface(anchors, x, z, originalY);
originalHeights[column] = originalY; originalHeights[column] = originalY;
targetHeights[column] = Math.max(area.minY(), Math.min( targetHeights[column] = Math.max(area.minY(), Math.min(
area.maxY(), resolveSurfaceTarget(anchors, x, z, originalY))); area.maxY(), resolution.targetY()));
rigidBaseSupport[column] = resolution.rigidBaseSupport();
} }
} }
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
@@ -188,7 +239,8 @@ public final class NativeStructureSurfaceFitter {
for (int x = area.minX(); x <= area.maxX(); x++) { for (int x = area.minX(); x <= area.maxX(); x++) {
int column = (z - area.minZ()) * width + x - area.minX(); int column = (z - area.minZ()) * width + x - area.minX();
applySurfaceColumn(world, position, x, z, applySurfaceColumn(world, position, x, z,
originalHeights[column], targetHeights[column], area.minY(), area.maxY()); originalHeights[column], targetHeights[column], area.minY(), area.maxY(),
rigidBaseSupport[column]);
} }
} }
} }
@@ -196,7 +248,18 @@ public final class NativeStructureSurfaceFitter {
static void applySurfaceColumn(WorldGenLevel world, BlockPos.MutableBlockPos position, static void applySurfaceColumn(WorldGenLevel world, BlockPos.MutableBlockPos position,
int x, int z, int originalY, int targetY, int x, int z, int originalY, int targetY,
int worldMinY, int worldMaxY) { int worldMinY, int worldMaxY) {
applySurfaceColumn(world, position, x, z, originalY, targetY,
worldMinY, worldMaxY, false);
}
static void applySurfaceColumn(WorldGenLevel world, BlockPos.MutableBlockPos position,
int x, int z, int originalY, int targetY,
int worldMinY, int worldMaxY,
boolean requireRigidBaseSupport) {
if (targetY == originalY) { if (targetY == originalY) {
if (requireRigidBaseSupport) {
ensureRigidBaseTerrain(world, position, x, z, targetY, worldMinY);
}
return; return;
} }
SurfaceMaterials materials = resolveSurfaceMaterials(world, position, x, z, originalY, worldMinY); SurfaceMaterials materials = resolveSurfaceMaterials(world, position, x, z, originalY, worldMinY);
@@ -207,6 +270,9 @@ public final class NativeStructureSurfaceFitter {
world.setBlock(position.set(x, y, z), clearedState, 2); world.setBlock(position.set(x, y, z), clearedState, 2);
} }
world.setBlock(position.set(x, targetY, z), materials.surface(), 2); world.setBlock(position.set(x, targetY, z), materials.surface(), 2);
if (requireRigidBaseSupport) {
ensureRigidBaseTerrain(world, position, x, z, targetY, worldMinY, materials);
}
return; return;
} }
for (int y = originalY + 1; y < targetY; y++) { for (int y = originalY + 1; y < targetY; y++) {
@@ -219,6 +285,42 @@ public final class NativeStructureSurfaceFitter {
if (!NativeStructureVegetationClearer.isTreeBlock(world.getBlockState(position))) { if (!NativeStructureVegetationClearer.isTreeBlock(world.getBlockState(position))) {
world.setBlock(position, materials.surface(), 2); world.setBlock(position, materials.surface(), 2);
} }
if (requireRigidBaseSupport) {
ensureRigidBaseTerrain(world, position, x, z, targetY, worldMinY, materials);
}
}
private static void ensureRigidBaseTerrain(WorldGenLevel world,
BlockPos.MutableBlockPos position,
int x, int z, int targetY, int worldMinY) {
int supportY = targetY - 1;
boolean targetIsTerrain = isTerrainBlock(world.getBlockState(position.set(x, targetY, z)));
boolean supportIsTerrain = supportY < worldMinY
|| isTerrainBlock(world.getBlockState(position.set(x, supportY, z)));
if (targetIsTerrain && supportIsTerrain) {
return;
}
SurfaceMaterials materials = resolveSurfaceMaterials(
world, position, x, z, targetY, worldMinY);
ensureRigidBaseTerrain(world, position, x, z, targetY, worldMinY, materials);
}
private static void ensureRigidBaseTerrain(WorldGenLevel world,
BlockPos.MutableBlockPos position,
int x, int z, int targetY, int worldMinY,
SurfaceMaterials materials) {
int supportY = targetY - 1;
position.set(x, targetY, z);
if (!isTerrainBlock(world.getBlockState(position))) {
world.setBlock(position, materials.surface(), 2);
}
if (supportY < worldMinY) {
return;
}
position.set(x, supportY, z);
if (!isTerrainBlock(world.getBlockState(position))) {
world.setBlock(position, materials.subsurface(), 2);
}
} }
private static BlockState clearSurfaceDecorationAndResolveFill( private static BlockState clearSurfaceDecorationAndResolveFill(
@@ -271,9 +373,20 @@ public final class NativeStructureSurfaceFitter {
return state.isSolid() && !NativeStructureVegetationClearer.isTreeBlock(state); return state.isSolid() && !NativeStructureVegetationClearer.isTreeBlock(state);
} }
record SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength) { record SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength,
int minInfluenceY, int maxInfluenceY) {
SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength) {
this(minX, maxX, minZ, maxZ, meetY, strength, meetY + 1, meetY + 1);
}
int verticalDistance(int y) {
return IrisObjectVacuum.outset(y, minInfluenceY, maxInfluenceY);
}
} }
private record SurfaceMaterials(BlockState surface, BlockState subsurface) { private record SurfaceMaterials(BlockState surface, BlockState subsurface) {
} }
private record SurfaceResolution(int targetY, boolean rigidBaseSupport) {
}
} }
@@ -0,0 +1,228 @@
package art.arcane.iris.nativegen;
import com.mojang.datafixers.util.Pair;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.Registry;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.pools.EmptyPoolElement;
import net.minecraft.world.level.levelgen.structure.pools.FeaturePoolElement;
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.pools.alias.DirectPoolAlias;
import net.minecraft.world.level.levelgen.structure.pools.alias.PoolAliasBinding;
import net.minecraft.world.level.levelgen.structure.pools.alias.RandomGroupPoolAlias;
import net.minecraft.world.level.levelgen.structure.pools.alias.RandomPoolAlias;
import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import java.util.Collections;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
public final class NativeStructureTemplatePoolBounds {
private NativeStructureTemplatePoolBounds() {
}
public static int sourceHorizontalSpan(RegistryAccess registryAccess,
StructureTemplateManager templateManager,
JigsawStructure source) {
Registry<StructureTemplatePool> pools = registryAccess.lookupOrThrow(Registries.TEMPLATE_POOL);
return sourceHorizontalSpan(
templateManager,
source.getStartPool(),
source.getPoolAliases(),
key -> pools.getValue(key.identifier()));
}
public static int horizontalSpan(RegistryAccess registryAccess,
StructureTemplateManager templateManager,
String templatePoolKey) {
Identifier identifier = Identifier.tryParse(templatePoolKey);
if (identifier == null) {
throw new IllegalArgumentException("Invalid registered template pool key: " + templatePoolKey);
}
Registry<StructureTemplatePool> pools = registryAccess.lookupOrThrow(Registries.TEMPLATE_POOL);
StructureTemplatePool pool = pools.getValue(identifier);
if (pool == null) {
throw new IllegalArgumentException("Registered template pool does not exist: " + templatePoolKey);
}
return horizontalSpan(pool, templateManager);
}
public static int horizontalSpan(RegistryAccess registryAccess,
StructureTemplateManager templateManager,
JigsawStructure source,
String templatePoolKey) {
Identifier identifier = Identifier.tryParse(templatePoolKey);
if (identifier == null) {
throw new IllegalArgumentException("Invalid registered template pool key: " + templatePoolKey);
}
Registry<StructureTemplatePool> pools = registryAccess.lookupOrThrow(Registries.TEMPLATE_POOL);
ResourceKey<StructureTemplatePool> startPoolKey = ResourceKey.create(
Registries.TEMPLATE_POOL, identifier);
StructureTemplatePool startPool = pools.getValue(identifier);
if (startPool == null) {
throw new IllegalArgumentException("Registered template pool does not exist: " + templatePoolKey);
}
return sourceHorizontalSpan(
templateManager,
startPool,
startPoolKey,
source.getPoolAliases(),
key -> pools.getValue(key.identifier()));
}
static int sourceHorizontalSpan(StructureTemplateManager templateManager,
Holder<StructureTemplatePool> startPool,
List<PoolAliasBinding> aliases,
Function<ResourceKey<StructureTemplatePool>, StructureTemplatePool> poolLookup) {
return sourceHorizontalSpan(templateManager, startPool.value(),
startPool.unwrapKey().orElse(null), aliases, poolLookup);
}
static int sourceHorizontalSpan(StructureTemplateManager templateManager,
StructureTemplatePool startPool,
ResourceKey<StructureTemplatePool> startPoolKey,
List<PoolAliasBinding> aliases,
Function<ResourceKey<StructureTemplatePool>, StructureTemplatePool> poolLookup) {
int maximumSpan = directHorizontalSpan(startPool, templateManager);
if (startPoolKey == null) {
return maximumSpan;
}
Set<ResourceKey<StructureTemplatePool>> targets = new HashSet<>();
for (PoolAliasBinding binding : aliases) {
collectTargets(binding, startPoolKey, targets);
}
for (ResourceKey<StructureTemplatePool> target : targets) {
StructureTemplatePool targetPool = poolLookup.apply(target);
if (targetPool == null) {
throw new IllegalStateException("Jigsaw start-pool alias target does not exist: "
+ target.identifier());
}
maximumSpan = Math.max(maximumSpan, directHorizontalSpan(targetPool, templateManager));
}
return maximumSpan;
}
private static void collectTargets(PoolAliasBinding binding,
ResourceKey<StructureTemplatePool> startPoolKey,
Set<ResourceKey<StructureTemplatePool>> targets) {
if (binding instanceof DirectPoolAlias direct) {
if (direct.alias().equals(startPoolKey)) {
targets.add(direct.target());
}
return;
}
if (binding instanceof RandomPoolAlias random) {
if (random.alias().equals(startPoolKey)) {
random.targets().unwrap().forEach(entry -> targets.add(entry.value()));
}
return;
}
if (binding instanceof RandomGroupPoolAlias group) {
group.groups().unwrap().forEach(entry -> entry.value().forEach(
nested -> collectTargets(nested, startPoolKey, targets)));
return;
}
throw new IllegalStateException("Unsupported jigsaw pool alias type: "
+ binding.getClass().getName());
}
static int horizontalSpan(StructureTemplatePool pool,
StructureTemplateManager templateManager) {
Set<StructureTemplatePool> visited = Collections.newSetFromMap(new IdentityHashMap<>());
return horizontalSpan(pool, templateManager, visited);
}
private static int horizontalSpan(StructureTemplatePool pool,
StructureTemplateManager templateManager,
Set<StructureTemplatePool> visited) {
if (!visited.add(pool)) {
return 0;
}
int maximumSpan = directHorizontalSpan(pool, templateManager);
Holder<StructureTemplatePool> fallback = pool.getFallback();
if (fallback == null || !fallback.isBound()) {
throw new IllegalStateException("Template pool has an unresolved fallback");
}
return Math.max(maximumSpan, horizontalSpan(fallback.value(), templateManager, visited));
}
private static int directHorizontalSpan(StructureTemplatePool pool,
StructureTemplateManager templateManager) {
int maximumSpan = 0;
for (Pair<StructurePoolElement, Integer> entry : pool.getTemplates()) {
StructurePoolElement element = entry.getFirst();
validateElement(element, templateManager);
maximumSpan = Math.max(maximumSpan, horizontalSpan(element, templateManager));
}
return maximumSpan;
}
private static void validateElement(StructurePoolElement element,
StructureTemplateManager templateManager) {
if (element == EmptyPoolElement.INSTANCE || element instanceof FeaturePoolElement) {
return;
}
if (element instanceof SinglePoolElement single) {
Identifier template;
try {
template = single.getTemplateLocation();
} catch (RuntimeException inlineTemplate) {
return;
}
if (templateManager.get(template).isEmpty()) {
throw new IllegalStateException("Template pool element does not resolve a structure template: "
+ template);
}
return;
}
if (element instanceof ListPoolElement list) {
for (StructurePoolElement child : list.getElements()) {
validateElement(child, templateManager);
}
return;
}
}
private static int horizontalSpan(StructurePoolElement element,
StructureTemplateManager templateManager) {
if (element == EmptyPoolElement.INSTANCE) {
return 0;
}
long maximumSpan = 0L;
for (Rotation rotation : Rotation.values()) {
BoundingBox box;
try {
box = element.getBoundingBox(templateManager, BlockPos.ZERO, rotation);
} catch (RuntimeException | LinkageError error) {
throw new IllegalStateException("Template pool element "
+ element.getClass().getName()
+ " could not provide bounded geometry for rotation " + rotation, error);
}
if (box == null) {
throw new IllegalStateException("Template pool element "
+ element.getClass().getName()
+ " returned no bounded geometry for rotation " + rotation);
}
long xSpan = (long) box.maxX() - box.minX() + 1L;
long zSpan = (long) box.maxZ() - box.minZ() + 1L;
maximumSpan = Math.max(maximumSpan, Math.max(xSpan, zSpan));
}
if (maximumSpan < 0L || maximumSpan > Integer.MAX_VALUE) {
throw new IllegalStateException("Template pool element has an unbounded horizontal span");
}
return (int) maximumSpan;
}
}
@@ -9,16 +9,24 @@ import art.arcane.iris.engine.object.IrisStructureTerrainMode;
import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.noise.CNG;
import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.RNG;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.Rotation; import net.minecraft.world.level.block.Rotation;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.dimension.BuiltinDimensionTypes;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.BoundingBox;
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
import net.minecraft.world.level.levelgen.structure.StructurePiece; import net.minecraft.world.level.levelgen.structure.StructurePiece;
import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.TerrainAdjustment; import net.minecraft.world.level.levelgen.structure.TerrainAdjustment;
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.LegacySinglePoolElement;
import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement; 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.SinglePoolElement;
@@ -29,62 +37,80 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.BitSet;
import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier; import java.util.function.Supplier;
public final class NativeStructureTerrainIntegrator { public final class NativeStructureTerrainIntegrator {
private static final int AUTO_ENCASE_PADDING = 3;
private static final long CARVE_CEILING_ROLL_SIGNATURE = 0x2A17L; private static final long CARVE_CEILING_ROLL_SIGNATURE = 0x2A17L;
private static final long CARVE_FLOOR_ROLL_SIGNATURE = 0x5B3DL; private static final long CARVE_FLOOR_ROLL_SIGNATURE = 0x5B3DL;
private static final long CARVE_LOBE_SIGNATURE = 0x7C41L; private static final long CARVE_LOBE_SIGNATURE = 0x7C41L;
private static final int MAX_CACHED_CARVE_FOOTPRINTS = 4; private static final int MAX_CACHED_CARVE_CELLS = 2_000_000;
private static final int MAX_CARVE_COLUMNS = 2_000_000; private static final int MAX_CARVE_COLUMNS = 2_000_000;
private static final int MAX_TEMPLATE_OCCUPANCY_CELLS = 4_194_304; private static final int MAX_TEMPLATE_OCCUPANCY_CELLS = 4_194_304;
private static final int SOURCE_BURY_HORIZONTAL_RADIUS = 6;
private static final int SOURCE_BURY_VERTICAL_RADIUS = 12;
private static final int SOURCE_ENCAPSULATE_RADIUS = 12;
private static final int SOURCE_JUNCTION_RADIUS = 12;
private static final int SOURCE_MATERIAL_SAMPLE_RADIUS = 8;
private static final List<Block> TEMPLATE_VOID_BLOCKS = List.of(Blocks.AIR, Blocks.STRUCTURE_VOID); private static final List<Block> TEMPLATE_VOID_BLOCKS = List.of(Blocks.AIR, Blocks.STRUCTURE_VOID);
private static final Map<CarveFootprintKey, StructureCarvingFootprint> CARVE_FOOTPRINTS = private static final Set<Block> VANILLA_SOURCE_TERRAIN_BLOCKS = Set.of(
Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75F, true) { Blocks.STONE, Blocks.GRANITE, Blocks.DIORITE, Blocks.ANDESITE,
@Override Blocks.TUFF, Blocks.DEEPSLATE, Blocks.NETHERRACK, Blocks.BASALT,
protected boolean removeEldestEntry( Blocks.BLACKSTONE, Blocks.DIRT, Blocks.COARSE_DIRT, Blocks.ROOTED_DIRT,
Map.Entry<CarveFootprintKey, StructureCarvingFootprint> eldest) { Blocks.GRASS_BLOCK, Blocks.PODZOL, Blocks.MYCELIUM, Blocks.MUD,
return size() > MAX_CACHED_CARVE_FOOTPRINTS; Blocks.MOSS_BLOCK, Blocks.SAND, Blocks.RED_SAND, Blocks.TERRACOTTA,
} Blocks.CRIMSON_NYLIUM, Blocks.WARPED_NYLIUM, Blocks.SNOW_BLOCK,
}); Blocks.ICE, Blocks.PACKED_ICE, Blocks.BLUE_ICE, Blocks.SOUL_SAND,
Blocks.SOUL_SOIL, Blocks.END_STONE, Blocks.GRAVEL, Blocks.CLAY,
Blocks.CALCITE, Blocks.DRIPSTONE_BLOCK, Blocks.SANDSTONE,
Blocks.RED_SANDSTONE, Blocks.SCULK);
private static final Map<CarveFootprintKey, CachedCarveFootprint> CARVE_FOOTPRINTS =
new LinkedHashMap<>(16, 0.75F, true);
private static final ConcurrentHashMap<CarveFootprintKey, CompletableFuture<StructureCarvingFootprint>>
CARVE_FOOTPRINT_BUILDS = new ConcurrentHashMap<>();
private static int cachedCarveCells;
private NativeStructureTerrainIntegrator() { private NativeStructureTerrainIntegrator() {
} }
public static IrisStructureTerrain resolveNativeTerrain(StructureStart start, public static IrisStructureTerrain resolveNativeTerrain(StructureStart start,
IrisStructureTerrain configuredTerrain) { IrisStructureTerrain configuredTerrain) {
if (configuredTerrain != null) { return configuredTerrain == null
return configuredTerrain; ? new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE)
} : configuredTerrain;
if (start == null || !start.isValid()
|| !encasesTerrain(start.getStructure().terrainAdaptation())) {
return null;
}
return new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.ENCASE)
.setHorizontalPadding(AUTO_ENCASE_PADDING)
.setCeilingPadding(AUTO_ENCASE_PADDING)
.setFloorPadding(AUTO_ENCASE_PADDING);
}
static boolean encasesTerrain(TerrainAdjustment adjustment) {
return adjustment == TerrainAdjustment.BURY || adjustment == TerrainAdjustment.ENCAPSULATE;
} }
static void integrateTerrain(WorldGenLevel world, BoundingBox area, String structureId, static void integrateTerrain(WorldGenLevel world, BoundingBox area, String structureId,
StructureStart start, IrisStructureTerrain configuredTerrain, StructureStart start, IrisStructureTerrain configuredTerrain,
NativeStructurePostProcessor.PaletteBlockResolver paletteBlockResolver) { NativeStructurePostProcessor.PaletteBlockResolver paletteBlockResolver) {
SourceTerrainSnapshot sourceTerrain = requiresSourceTerrainFill(start, configuredTerrain)
? captureSourceTerrain(world, area, start, configuredTerrain) : null;
integrateTerrain(world, area, structureId, start, configuredTerrain,
paletteBlockResolver, sourceTerrain);
}
static void integrateTerrain(WorldGenLevel world, BoundingBox area, String structureId,
StructureStart start, IrisStructureTerrain configuredTerrain,
NativeStructurePostProcessor.PaletteBlockResolver paletteBlockResolver,
SourceTerrainSnapshot sourceTerrain) {
IrisStructureTerrain terrain = configuredTerrain == null IrisStructureTerrain terrain = configuredTerrain == null
? new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE) ? new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE)
: configuredTerrain; : configuredTerrain;
IrisStructureTerrainMode mode = terrain.resolvedMode(); IrisStructureTerrainMode mode = terrain.resolvedMode();
if (mode == IrisStructureTerrainMode.SOURCE || mode == IrisStructureTerrainMode.PRESERVE) { if (mode == IrisStructureTerrainMode.SOURCE) {
integrateSourceTerrain(world, area, start, sourceTerrain);
return;
}
if (mode == IrisStructureTerrainMode.PRESERVE) {
return; return;
} }
if (mode == IrisStructureTerrainMode.VACUUM) { if (mode == IrisStructureTerrainMode.VACUUM) {
@@ -111,12 +137,381 @@ public final class NativeStructureTerrainIntegrator {
terrain, shape, carveNoiseIdentity(world, structureId, start))); terrain, shape, carveNoiseIdentity(world, structureId, start)));
} }
static SourceTerrainSnapshot captureSourceTerrain(
WorldGenLevel world, BoundingBox area, List<TerrainTarget> targets) {
BitSet requiredLayers = new BitSet(area.getYSpan());
boolean requiresSnapshot = false;
for (TerrainTarget target : targets) {
if (target != null && requiresSourceTerrainFill(target.start(), target.terrain())) {
requiresSnapshot = true;
markSourceTerrainLayers(area, target.start(), target.terrain(), requiredLayers);
}
}
return requiresSnapshot ? SourceTerrainSnapshot.capture(world, area, requiredLayers) : null;
}
private static SourceTerrainSnapshot captureSourceTerrain(
WorldGenLevel world, BoundingBox area, StructureStart start,
IrisStructureTerrain configuredTerrain) {
BitSet requiredLayers = new BitSet(area.getYSpan());
markSourceTerrainLayers(area, start, configuredTerrain, requiredLayers);
return SourceTerrainSnapshot.capture(world, area, requiredLayers);
}
private static void markSourceTerrainLayers(
BoundingBox area, StructureStart start, IrisStructureTerrain configuredTerrain,
BitSet requiredLayers) {
if (!requiresSourceTerrainFill(start, configuredTerrain)) {
return;
}
TerrainAdjustment adjustment = start.getStructure().terrainAdaptation();
for (StructurePiece piece : start.getPieces()) {
if (!isSourceRigidPiece(piece)) {
continue;
}
BoundingBox bounds = piece.getBoundingBox();
long horizontalDistanceSquared = minimumHorizontalDistanceSquared(area, bounds);
if (adjustment == TerrainAdjustment.BURY) {
long remainingDistanceSquared = (long) SOURCE_BURY_VERTICAL_RADIUS
* SOURCE_BURY_VERTICAL_RADIUS - 1L - horizontalDistanceSquared * 4L;
if (remainingDistanceSquared < 0L) {
continue;
}
int verticalRadius = (int) Math.floor(Math.sqrt(remainingDistanceSquared));
long groundY = bounds.minY();
if (piece instanceof PoolElementStructurePiece poolPiece) {
groundY += poolPiece.getGroundLevelDelta();
}
markLayers(area, requiredLayers,
groundY - verticalRadius, groundY + verticalRadius);
} else {
long remainingDistanceSquared = (long) SOURCE_ENCAPSULATE_RADIUS
* SOURCE_ENCAPSULATE_RADIUS - 1L - horizontalDistanceSquared;
if (remainingDistanceSquared < 0L) {
continue;
}
int verticalRadius = (int) Math.floor(Math.sqrt(remainingDistanceSquared));
markLayers(area, requiredLayers,
(long) bounds.minY() - verticalRadius,
(long) bounds.maxY() + verticalRadius);
}
}
markSourceJunctionLayers(area, start, requiredLayers);
}
private static void markSourceJunctionLayers(
BoundingBox area, StructureStart start, BitSet requiredLayers) {
Set<JunctionAnchor> anchors = sourceJunctionAnchors(start);
for (JunctionAnchor anchor : anchors) {
long deltaX = intervalDistance(area.minX(), area.maxX(), anchor.x(), anchor.x());
long deltaZ = intervalDistance(area.minZ(), area.maxZ(), anchor.z(), anchor.z());
long horizontalDistanceSquared = deltaX * deltaX + deltaZ * deltaZ;
long remainingDistanceSquared = (long) SOURCE_JUNCTION_RADIUS
* SOURCE_JUNCTION_RADIUS - 1L - horizontalDistanceSquared;
if (remainingDistanceSquared < 1L) {
continue;
}
int verticalRadius = (int) Math.floor(Math.sqrt(remainingDistanceSquared));
markLayers(area, requiredLayers,
(long) anchor.y() - verticalRadius, (long) anchor.y() - 1L);
}
}
private static long minimumHorizontalDistanceSquared(BoundingBox area, BoundingBox bounds) {
long deltaX = intervalDistance(area.minX(), area.maxX(), bounds.minX(), bounds.maxX());
long deltaZ = intervalDistance(area.minZ(), area.maxZ(), bounds.minZ(), bounds.maxZ());
return deltaX * deltaX + deltaZ * deltaZ;
}
private static long intervalDistance(int firstMin, int firstMax, int secondMin, int secondMax) {
if (firstMax < secondMin) {
return (long) secondMin - firstMax;
}
if (firstMin > secondMax) {
return (long) firstMin - secondMax;
}
return 0L;
}
private static void markLayers(
BoundingBox area, BitSet requiredLayers, long minimumY, long maximumY) {
long clippedMinimumY = Math.max(area.minY(), minimumY);
long clippedMaximumY = Math.min(area.maxY(), maximumY);
if (clippedMinimumY > clippedMaximumY) {
return;
}
int fromIndex = Math.toIntExact(clippedMinimumY - area.minY());
int toIndex = Math.toIntExact(clippedMaximumY - area.minY() + 1L);
requiredLayers.set(fromIndex, toIndex);
}
private static boolean requiresSourceTerrainFill(
StructureStart start, IrisStructureTerrain configuredTerrain) {
if (start == null || !start.isValid()) {
return false;
}
IrisStructureTerrain terrain = configuredTerrain == null
? new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE)
: configuredTerrain;
if (terrain.resolvedMode() != IrisStructureTerrainMode.SOURCE) {
return false;
}
TerrainAdjustment adjustment = start.getStructure().terrainAdaptation();
return adjustment == TerrainAdjustment.BURY
|| adjustment == TerrainAdjustment.ENCAPSULATE;
}
static boolean clearsLegacyTemplateAir(StructureStart start, IrisStructureTerrain terrain) {
if (start == null || !start.isValid() || terrain == null) {
return false;
}
IrisStructureTerrainMode mode = terrain.resolvedMode();
return mode == IrisStructureTerrainMode.ENCASE
|| mode == IrisStructureTerrainMode.SOURCE
&& start.getStructure().terrainAdaptation() != TerrainAdjustment.NONE;
}
private static void integrateSourceTerrain(WorldGenLevel world, BoundingBox area,
StructureStart start,
SourceTerrainSnapshot sourceTerrain) {
if (start == null || !start.isValid()) {
return;
}
TerrainAdjustment adjustment = start.getStructure().terrainAdaptation();
if (adjustment == TerrainAdjustment.BURY) {
fillBuriedTerrain(world, area, start, requireSourceTerrain(
world, area, start, sourceTerrain));
} else if (adjustment == TerrainAdjustment.ENCAPSULATE) {
fillEncapsulatedTerrain(world, area, start, requireSourceTerrain(
world, area, start, sourceTerrain));
}
}
private static SourceTerrainSnapshot requireSourceTerrain(
WorldGenLevel world, BoundingBox area, StructureStart start,
SourceTerrainSnapshot sourceTerrain) {
return sourceTerrain == null
? captureSourceTerrain(world, area, start, null) : sourceTerrain;
}
private static void fillBuriedTerrain(WorldGenLevel world, BoundingBox area,
StructureStart start,
SourceTerrainSnapshot sourceTerrain) {
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
for (StructurePiece piece : start.getPieces()) {
if (!isSourceRigidPiece(piece)) {
continue;
}
BoundingBox bounds = piece.getBoundingBox();
int groundY = bounds.minY();
if (piece instanceof PoolElementStructurePiece poolPiece) {
groundY += poolPiece.getGroundLevelDelta();
}
int minX = Math.max(area.minX(), bounds.minX() - SOURCE_BURY_HORIZONTAL_RADIUS);
int maxX = Math.min(area.maxX(), bounds.maxX() + SOURCE_BURY_HORIZONTAL_RADIUS);
int minY = Math.max(area.minY(), groundY - SOURCE_BURY_VERTICAL_RADIUS);
int maxY = Math.min(area.maxY(), groundY + SOURCE_BURY_VERTICAL_RADIUS);
int minZ = Math.max(area.minZ(), bounds.minZ() - SOURCE_BURY_HORIZONTAL_RADIUS);
int maxZ = Math.min(area.maxZ(), bounds.maxZ() + SOURCE_BURY_HORIZONTAL_RADIUS);
for (int x = minX; x <= maxX; x++) {
int outX = outset(x, bounds.minX(), bounds.maxX());
for (int z = minZ; z <= maxZ; z++) {
int outZ = outset(z, bounds.minZ(), bounds.maxZ());
for (int y = minY; y <= maxY; y++) {
int vertical = Math.abs(y - groundY);
if (!insideBurialEnvelope(outX, vertical, outZ)) {
continue;
}
fillEncaseable(world, position.set(x, y, z), sourceTerrain);
}
}
}
}
fillSourceJunctionTerrain(world, area, start, position, sourceTerrain);
}
private static void fillEncapsulatedTerrain(WorldGenLevel world, BoundingBox area,
StructureStart start,
SourceTerrainSnapshot sourceTerrain) {
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
for (StructurePiece piece : start.getPieces()) {
if (!isSourceRigidPiece(piece)) {
continue;
}
BoundingBox bounds = piece.getBoundingBox();
int minX = Math.max(area.minX(), bounds.minX() - SOURCE_ENCAPSULATE_RADIUS);
int maxX = Math.min(area.maxX(), bounds.maxX() + SOURCE_ENCAPSULATE_RADIUS);
int minY = Math.max(area.minY(), bounds.minY() - SOURCE_ENCAPSULATE_RADIUS);
int maxY = Math.min(area.maxY(), bounds.maxY() + SOURCE_ENCAPSULATE_RADIUS);
int minZ = Math.max(area.minZ(), bounds.minZ() - SOURCE_ENCAPSULATE_RADIUS);
int maxZ = Math.min(area.maxZ(), bounds.maxZ() + SOURCE_ENCAPSULATE_RADIUS);
for (int x = minX; x <= maxX; x++) {
int outX = outset(x, bounds.minX(), bounds.maxX());
for (int z = minZ; z <= maxZ; z++) {
int outZ = outset(z, bounds.minZ(), bounds.maxZ());
for (int y = minY; y <= maxY; y++) {
int outY = outset(y, bounds.minY(), bounds.maxY());
if (!insideEncapsulationEnvelope(outX, outY, outZ)) {
continue;
}
fillEncaseable(world, position.set(x, y, z), sourceTerrain);
}
}
}
}
fillSourceJunctionTerrain(world, area, start, position, sourceTerrain);
}
static boolean insideBurialEnvelope(int outX, int verticalDistance, int outZ) {
long horizontalSquared = (long) outX * outX + (long) outZ * outZ;
long verticalSquared = (long) verticalDistance * verticalDistance;
return horizontalSquared * 4L + verticalSquared
< (long) SOURCE_BURY_VERTICAL_RADIUS * SOURCE_BURY_VERTICAL_RADIUS;
}
static boolean insideEncapsulationEnvelope(int outX, int outY, int outZ) {
return (long) outX * outX + (long) outY * outY + (long) outZ * outZ
< (long) SOURCE_ENCAPSULATE_RADIUS * SOURCE_ENCAPSULATE_RADIUS;
}
static boolean isSourceRigidPiece(StructurePiece piece) {
if (piece == null) {
return false;
}
return !(piece instanceof PoolElementStructurePiece poolPiece)
|| poolPiece.getElement().getProjection() == StructureTemplatePool.Projection.RIGID;
}
static boolean insideSourceJunctionEnvelope(int deltaX, int deltaY, int deltaZ) {
if (deltaY >= 0) {
return false;
}
return (long) deltaX * deltaX + (long) deltaY * deltaY + (long) deltaZ * deltaZ
< (long) SOURCE_JUNCTION_RADIUS * SOURCE_JUNCTION_RADIUS;
}
private static void fillSourceJunctionTerrain(WorldGenLevel world, BoundingBox area,
StructureStart start,
BlockPos.MutableBlockPos position,
SourceTerrainSnapshot sourceTerrain) {
Set<JunctionAnchor> anchors = sourceJunctionAnchors(start);
for (JunctionAnchor anchor : anchors) {
int minX = Math.max(area.minX(), anchor.x() - SOURCE_JUNCTION_RADIUS + 1);
int maxX = Math.min(area.maxX(), anchor.x() + SOURCE_JUNCTION_RADIUS - 1);
int minY = Math.max(area.minY(), anchor.y() - SOURCE_JUNCTION_RADIUS + 1);
int maxY = Math.min(area.maxY(), anchor.y() - 1);
int minZ = Math.max(area.minZ(), anchor.z() - SOURCE_JUNCTION_RADIUS + 1);
int maxZ = Math.min(area.maxZ(), anchor.z() + SOURCE_JUNCTION_RADIUS - 1);
for (int x = minX; x <= maxX; x++) {
int deltaX = x - anchor.x();
for (int z = minZ; z <= maxZ; z++) {
int deltaZ = z - anchor.z();
for (int y = minY; y <= maxY; y++) {
if (insideSourceJunctionEnvelope(deltaX, y - anchor.y(), deltaZ)) {
fillEncaseable(world, position.set(x, y, z), sourceTerrain);
}
}
}
}
}
}
private static Set<JunctionAnchor> sourceJunctionAnchors(StructureStart start) {
Set<JunctionAnchor> anchors = new HashSet<>();
for (StructurePiece piece : start.getPieces()) {
if (!(piece instanceof PoolElementStructurePiece poolPiece)) {
continue;
}
for (JigsawJunction junction : poolPiece.getJunctions()) {
anchors.add(new JunctionAnchor(
junction.getSourceX(), junction.getSourceGroundY(), junction.getSourceZ()));
}
}
return anchors;
}
private static int outset(int value, int minimum, int maximum) {
if (value < minimum) {
return minimum - value;
}
return Math.max(0, value - maximum);
}
private static void fillEncaseable(WorldGenLevel world, BlockPos position,
SourceTerrainSnapshot sourceTerrain) {
if (isEncaseable(world.getBlockState(position))) {
world.setBlock(position, sourceEncaseBlock(world, position, sourceTerrain), 2);
}
}
static BlockState sourceEncaseBlock(WorldGenLevel world, BlockPos position) {
int minX = Math.subtractExact(position.getX(), SOURCE_MATERIAL_SAMPLE_RADIUS);
int minZ = Math.subtractExact(position.getZ(), SOURCE_MATERIAL_SAMPLE_RADIUS);
int maxX = Math.addExact(position.getX(), SOURCE_MATERIAL_SAMPLE_RADIUS);
int maxZ = Math.addExact(position.getZ(), SOURCE_MATERIAL_SAMPLE_RADIUS);
BoundingBox sampleArea = new BoundingBox(minX, position.getY(), minZ,
maxX, position.getY(), maxZ);
BitSet requiredLayers = new BitSet(1);
requiredLayers.set(0);
SourceTerrainSnapshot sourceTerrain = SourceTerrainSnapshot.capture(
world, sampleArea, requiredLayers);
return sourceEncaseBlock(world, position, sourceTerrain);
}
static BlockState sourceEncaseBlock(WorldGenLevel world, BlockPos position,
SourceTerrainSnapshot sourceTerrain) {
BlockPos.MutableBlockPos probe = new BlockPos.MutableBlockPos();
for (int distance = 1; distance <= SOURCE_MATERIAL_SAMPLE_RADIUS; distance++) {
BlockState sampled = sourceTerrain.stateAt(probe.set(
position.getX() - distance, position.getY(), position.getZ()));
if (sampled != null) {
return sampled;
}
sampled = sourceTerrain.stateAt(probe.set(
position.getX() + distance, position.getY(), position.getZ()));
if (sampled != null) {
return sampled;
}
sampled = sourceTerrain.stateAt(probe.set(
position.getX(), position.getY(), position.getZ() - distance));
if (sampled != null) {
return sampled;
}
sampled = sourceTerrain.stateAt(probe.set(
position.getX(), position.getY(), position.getZ() + distance));
if (sampled != null) {
return sampled;
}
}
return defaultEncaseBlock(world, position.getY());
}
private static BlockState sourceTerrainBlock(BlockState state) {
if (!state.isSolid() || NativeStructureVegetationClearer.isTreeBlock(state)) {
return null;
}
return VANILLA_SOURCE_TERRAIN_BLOCKS.contains(state.getBlock())
|| state.is(BlockTags.BASE_STONE_OVERWORLD)
|| state.is(BlockTags.BASE_STONE_NETHER)
|| state.is(BlockTags.SUBSTRATE_OVERWORLD)
|| state.is(BlockTags.DIRT)
|| state.is(BlockTags.SAND)
|| state.is(BlockTags.TERRACOTTA)
|| state.is(BlockTags.MUD)
|| state.is(BlockTags.MOSS_BLOCKS)
|| state.is(BlockTags.GRASS_BLOCKS)
|| state.is(BlockTags.NYLIUM)
|| state.is(BlockTags.SNOW)
|| state.is(BlockTags.ICE)
|| state.is(BlockTags.CORAL_BLOCKS)
|| state.is(BlockTags.SOUL_FIRE_BASE_BLOCKS)
? state : null;
}
static List<BoundingBox> contentPieceBounds(StructureStart start) { static List<BoundingBox> contentPieceBounds(StructureStart start) {
List<BoundingBox> bounds = new ArrayList<>(start.getPieces().size()); List<BoundingBox> bounds = new ArrayList<>(start.getPieces().size());
for (StructurePiece piece : start.getPieces()) { for (StructurePiece piece : start.getPieces()) {
if (!NativeStructureReferenceEnvelope.isMarker(piece)) { bounds.add(piece.getBoundingBox());
bounds.add(piece.getBoundingBox());
}
} }
return List.copyOf(bounds); return List.copyOf(bounds);
} }
@@ -128,18 +523,84 @@ public final class NativeStructureTerrainIntegrator {
static StructureCarvingFootprint carveFootprint(StructureStart start, int horizontalPadding, static StructureCarvingFootprint carveFootprint(StructureStart start, int horizontalPadding,
Supplier<StructureTemplateManager> templates) { Supplier<StructureTemplateManager> templates) {
CarveFootprintKey key = new CarveFootprintKey(start, horizontalPadding); CarveFootprintKey key = new CarveFootprintKey(start, horizontalPadding);
StructureCarvingFootprint cached = CARVE_FOOTPRINTS.get(key); StructureCarvingFootprint cached = cachedCarveFootprint(key);
if (cached != null) { if (cached != null) {
return cached; return cached;
} }
StructureCarvingFootprint footprint = StructureCarvingFootprint.fromColumns( CompletableFuture<StructureCarvingFootprint> build = new CompletableFuture<>();
sink -> emitCarveColumns(start, templates, sink), horizontalPadding, MAX_CARVE_COLUMNS); CompletableFuture<StructureCarvingFootprint> active = CARVE_FOOTPRINT_BUILDS.putIfAbsent(key, build);
if (footprint == null) { if (active != null) {
throw new IllegalStateException("Native structure carve footprint is empty or exceeds " return awaitCarveFootprint(active);
+ MAX_CARVE_COLUMNS + " columns"); }
try {
StructureCarvingFootprint footprint = StructureCarvingFootprint.fromColumns(
sink -> emitCarveColumns(start, templates, sink), horizontalPadding, MAX_CARVE_COLUMNS);
if (footprint == null) {
throw new IllegalStateException("Native structure carve footprint is empty or exceeds "
+ MAX_CARVE_COLUMNS + " columns");
}
cacheCarveFootprint(key, footprint);
build.complete(footprint);
return footprint;
} catch (RuntimeException | Error error) {
build.completeExceptionally(error);
throw error;
} finally {
CARVE_FOOTPRINT_BUILDS.remove(key, build);
}
}
static int cachedCarveFootprintCells() {
synchronized (CARVE_FOOTPRINTS) {
return cachedCarveCells;
}
}
static int maximumCachedCarveFootprintCells() {
return MAX_CACHED_CARVE_CELLS;
}
private static StructureCarvingFootprint cachedCarveFootprint(CarveFootprintKey key) {
synchronized (CARVE_FOOTPRINTS) {
CachedCarveFootprint cached = CARVE_FOOTPRINTS.get(key);
return cached == null ? null : cached.footprint();
}
}
private static StructureCarvingFootprint awaitCarveFootprint(
CompletableFuture<StructureCarvingFootprint> future) {
try {
return future.join();
} catch (CompletionException error) {
Throwable cause = error.getCause();
if (cause instanceof RuntimeException runtime) {
throw runtime;
}
if (cause instanceof Error fatal) {
throw fatal;
}
throw new IllegalStateException("Native structure carve footprint build failed", cause);
}
}
private static void cacheCarveFootprint(CarveFootprintKey key,
StructureCarvingFootprint footprint) {
int cells = Math.multiplyExact(footprint.width(), footprint.depth());
synchronized (CARVE_FOOTPRINTS) {
CachedCarveFootprint previous = CARVE_FOOTPRINTS.remove(key);
if (previous != null) {
cachedCarveCells -= previous.cells();
}
while (!CARVE_FOOTPRINTS.isEmpty()
&& cachedCarveCells + cells > MAX_CACHED_CARVE_CELLS) {
Map.Entry<CarveFootprintKey, CachedCarveFootprint> eldest =
CARVE_FOOTPRINTS.entrySet().iterator().next();
cachedCarveCells -= eldest.getValue().cells();
CARVE_FOOTPRINTS.remove(eldest.getKey());
}
CARVE_FOOTPRINTS.put(key, new CachedCarveFootprint(footprint, cells));
cachedCarveCells += cells;
} }
CARVE_FOOTPRINTS.put(key, footprint);
return footprint;
} }
static OrganicCarve organicCarve(StructureCarvingFootprint footprint, IrisStructureTerrain terrain, static OrganicCarve organicCarve(StructureCarvingFootprint footprint, IrisStructureTerrain terrain,
@@ -235,9 +696,6 @@ public final class NativeStructureTerrainIntegrator {
Supplier<StructureTemplateManager> templates, Supplier<StructureTemplateManager> templates,
StructureCarvingFootprint.ColumnSink sink) { StructureCarvingFootprint.ColumnSink sink) {
for (StructurePiece piece : start.getPieces()) { for (StructurePiece piece : start.getPieces()) {
if (NativeStructureReferenceEnvelope.isMarker(piece)) {
continue;
}
BoundingBox bounds = piece.getBoundingBox(); BoundingBox bounds = piece.getBoundingBox();
if (!(piece instanceof PoolElementStructurePiece poolPiece) if (!(piece instanceof PoolElementStructurePiece poolPiece)
|| !emitTemplateColumns(pieceTemplates(poolPiece, templates), || !emitTemplateColumns(pieceTemplates(poolPiece, templates),
@@ -373,7 +831,7 @@ public final class NativeStructureTerrainIntegrator {
continue; continue;
} }
BlockState fill = palette == null BlockState fill = palette == null
? defaultEncaseBlock(y) ? defaultEncaseBlock(world, y)
: Objects.requireNonNull( : Objects.requireNonNull(
paletteBlockResolver.resolve(palette, rng, x, y, z), paletteBlockResolver.resolve(palette, rng, x, y, z),
"Encase palette returned no block for " + structureId + " at " "Encase palette returned no block for " + structureId + " at "
@@ -393,6 +851,41 @@ public final class NativeStructureTerrainIntegrator {
return y < 0 ? Blocks.DEEPSLATE.defaultBlockState() : Blocks.STONE.defaultBlockState(); return y < 0 ? Blocks.DEEPSLATE.defaultBlockState() : Blocks.STONE.defaultBlockState();
} }
static BlockState defaultEncaseBlock(WorldGenLevel world, int y) {
ServerLevel level = world == null ? null : world.getLevel();
if (level == null) {
return defaultEncaseBlock(y);
}
Holder<DimensionType> dimensionType = level.dimensionTypeRegistration();
ResourceKey<DimensionType> dimensionTypeKey = dimensionType.unwrapKey().orElse(null);
return defaultEncaseBlock(level.dimension(), dimensionTypeKey, dimensionType.value(), y);
}
static BlockState defaultEncaseBlock(ResourceKey<Level> dimension, int y) {
return defaultEncaseBlock(dimension, null, null, y);
}
static BlockState defaultEncaseBlock(ResourceKey<Level> dimension,
ResourceKey<DimensionType> dimensionTypeKey,
DimensionType dimensionType, int y) {
if (Level.NETHER.equals(dimension)) {
return Blocks.NETHERRACK.defaultBlockState();
}
if (Level.END.equals(dimension)) {
return Blocks.END_STONE.defaultBlockState();
}
if (BuiltinDimensionTypes.NETHER.equals(dimensionTypeKey)
|| dimensionType != null && dimensionType.hasCeiling() && !dimensionType.hasSkyLight()) {
return Blocks.NETHERRACK.defaultBlockState();
}
if (BuiltinDimensionTypes.END.equals(dimensionTypeKey)
|| dimensionType != null && (dimensionType.hasEnderDragonFight()
|| dimensionType.hasEndFlashes())) {
return Blocks.END_STONE.defaultBlockState();
}
return defaultEncaseBlock(y);
}
private static void carvePieceBoxes(WorldGenLevel world, BoundingBox area, StructureStart start, private static void carvePieceBoxes(WorldGenLevel world, BoundingBox area, StructureStart start,
IrisStructureTerrain terrain) { IrisStructureTerrain terrain) {
BlockState air = Blocks.AIR.defaultBlockState(); BlockState air = Blocks.AIR.defaultBlockState();
@@ -431,9 +924,6 @@ public final class NativeStructureTerrainIntegrator {
StructureStart start, StructureStart start,
Supplier<StructureTemplateManager> templates) { Supplier<StructureTemplateManager> templates) {
for (StructurePiece piece : start.getPieces()) { for (StructurePiece piece : start.getPieces()) {
if (NativeStructureReferenceEnvelope.isMarker(piece)) {
continue;
}
if (!(piece instanceof PoolElementStructurePiece poolPiece) if (!(piece instanceof PoolElementStructurePiece poolPiece)
|| poolPiece.getElement().getProjection() != StructureTemplatePool.Projection.RIGID || poolPiece.getElement().getProjection() != StructureTemplatePool.Projection.RIGID
|| !intersects(poolPiece.getBoundingBox(), area)) { || !intersects(poolPiece.getBoundingBox(), area)) {
@@ -496,6 +986,80 @@ public final class NativeStructureTerrainIntegrator {
private record CarveFootprintKey(StructureStart start, int padding) { private record CarveFootprintKey(StructureStart start, int padding) {
} }
private record CachedCarveFootprint(StructureCarvingFootprint footprint, int cells) {
}
private record JunctionAnchor(int x, int y, int z) {
}
static final class SourceTerrainSnapshot {
private final BoundingBox area;
private final int width;
private final BlockState[][] statesByLayer;
private final int sampledCells;
private SourceTerrainSnapshot(BoundingBox area, int width,
BlockState[][] statesByLayer, int sampledCells) {
this.area = area;
this.width = width;
this.statesByLayer = statesByLayer;
this.sampledCells = sampledCells;
}
static SourceTerrainSnapshot capture(
WorldGenLevel world, BoundingBox area, BitSet requiredLayers) {
Objects.requireNonNull(world, "Source terrain snapshot requires a generation level");
Objects.requireNonNull(area, "Source terrain snapshot requires writable bounds");
Objects.requireNonNull(requiredLayers, "Source terrain snapshot requires sampled layers");
int width = area.getXSpan();
int depth = area.getZSpan();
int height = area.getYSpan();
int horizontalCells = Math.multiplyExact(width, depth);
BlockState[][] statesByLayer = new BlockState[height][];
int sampledCells = 0;
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
for (int layer = requiredLayers.nextSetBit(0);
layer >= 0; layer = requiredLayers.nextSetBit(layer + 1)) {
if (layer >= height) {
throw new IllegalArgumentException(
"Source terrain snapshot layer exceeds writable bounds: " + layer);
}
BlockState[] states = new BlockState[horizontalCells];
statesByLayer[layer] = states;
sampledCells = Math.addExact(sampledCells, horizontalCells);
int y = Math.addExact(area.minY(), layer);
for (int z = area.minZ(); z <= area.maxZ(); z++) {
for (int x = area.minX(); x <= area.maxX(); x++) {
int index = (z - area.minZ()) * width + x - area.minX();
states[index] = sourceTerrainBlock(
world.getBlockState(position.set(x, y, z)));
}
}
}
return new SourceTerrainSnapshot(new BoundingBox(
area.minX(), area.minY(), area.minZ(),
area.maxX(), area.maxY(), area.maxZ()), width,
statesByLayer, sampledCells);
}
BlockState stateAt(BlockPos position) {
if (!area.isInside(position)) {
return null;
}
BlockState[] states = statesByLayer[position.getY() - area.minY()];
if (states == null) {
return null;
}
int index = (position.getZ() - area.minZ()) * width
+ position.getX() - area.minX();
return states[index];
}
int sampledCells() {
return sampledCells;
}
}
record OrganicCarve(StructureCarvingFootprint footprint, IrisStructureCarveShape shape, record OrganicCarve(StructureCarvingFootprint footprint, IrisStructureCarveShape shape,
int horizontalPadding, int ceilingPadding, int floorPadding, int horizontalPadding, int ceilingPadding, int floorPadding,
double strength, double frequency, CNG blob, CNG ceilingRoll, CNG floorRoll, double strength, double frequency, CNG blob, CNG ceilingRoll, CNG floorRoll,
@@ -0,0 +1,224 @@
package art.arcane.iris.nativegen;
import com.mojang.datafixers.util.Pair;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.SectionPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureCheckResult;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
public final class NativeStructureVanillaLocator {
private NativeStructureVanillaLocator() {
}
public static Candidate predict(ServerLevel level, HolderSet<Structure> holders,
BlockPos origin, int radius, boolean findUnexplored) {
if (SharedConstants.DEBUG_DISABLE_FEATURES
|| !level.getServer().getWorldGenSettings().options().generateStructures()) {
return null;
}
ChunkGeneratorStructureState state = level.getChunkSource().getGeneratorState();
Map<StructurePlacement, Set<Holder<Structure>>> byPlacement = new LinkedHashMap<>();
for (Holder<Structure> holder : holders) {
for (StructurePlacement placement : state.getPlacementsForStructure(holder)) {
byPlacement.computeIfAbsent(placement, ignored -> new LinkedHashSet<>()).add(holder);
}
}
if (byPlacement.isEmpty()) {
return null;
}
StructureManager structureManager = level.structureManager();
Candidate best = null;
double bestDistance = Double.MAX_VALUE;
List<Map.Entry<RandomSpreadStructurePlacement, Set<Holder<Structure>>>> randomPlacements =
new ArrayList<>(byPlacement.size());
for (Map.Entry<StructurePlacement, Set<Holder<Structure>>> entry : byPlacement.entrySet()) {
StructurePlacement placement = entry.getKey();
if (placement instanceof ConcentricRingsStructurePlacement concentric) {
Candidate candidate = predictConcentric(
entry.getValue(), level, structureManager, origin, findUnexplored,
state, concentric);
if (candidate != null) {
double distance = origin.distSqr(candidate.result().getFirst());
if (distance < bestDistance) {
best = candidate;
bestDistance = distance;
}
}
} else if (placement instanceof RandomSpreadStructurePlacement randomSpread) {
randomPlacements.add(Map.entry(randomSpread, entry.getValue()));
}
}
int centerChunkX = SectionPos.blockToSectionCoord(origin.getX());
int centerChunkZ = SectionPos.blockToSectionCoord(origin.getZ());
int searchRadius = Math.max(0, radius);
for (int ring = 0; ring <= searchRadius; ring++) {
boolean foundInRing = false;
for (Map.Entry<RandomSpreadStructurePlacement, Set<Holder<Structure>>> entry
: randomPlacements) {
Candidate candidate = predictRandomSpread(
entry.getValue(), level, structureManager,
centerChunkX, centerChunkZ, ring, findUnexplored,
state.getLevelSeed(), entry.getKey());
if (candidate == null) {
continue;
}
foundInRing = true;
double distance = origin.distSqr(candidate.result().getFirst());
if (distance < bestDistance) {
best = candidate;
bestDistance = distance;
}
}
if (foundInRing) {
return best;
}
}
return best;
}
private static Candidate predictConcentric(Set<Holder<Structure>> holders,
ServerLevel level,
StructureManager structureManager,
BlockPos origin,
boolean findUnexplored,
ChunkGeneratorStructureState state,
ConcentricRingsStructurePlacement placement) {
List<ChunkPos> positions = state.getRingPositionsFor(placement);
if (positions == null) {
throw new IllegalStateException(
"Tried to locate structures for an unavailable concentric placement");
}
Candidate best = null;
double bestDistance = Double.MAX_VALUE;
for (ChunkPos position : positions) {
BlockPos locatePosition = new BlockPos(
SectionPos.sectionToBlockCoord(position.x(), 8),
32,
SectionPos.sectionToBlockCoord(position.z(), 8));
double distance = locatePosition.distSqr(origin);
if (best != null && distance >= bestDistance) {
continue;
}
Candidate candidate = predictAt(
holders, level, structureManager, findUnexplored, placement, position);
if (candidate != null) {
best = candidate;
bestDistance = distance;
}
}
return best;
}
private static Candidate predictRandomSpread(Set<Holder<Structure>> holders,
ServerLevel level,
StructureManager structureManager,
int centerChunkX, int centerChunkZ,
int ring, boolean findUnexplored,
long seed,
RandomSpreadStructurePlacement placement) {
int spacing = placement.spacing();
for (int offsetX = -ring; offsetX <= ring; offsetX++) {
boolean edgeX = offsetX == -ring || offsetX == ring;
for (int offsetZ = -ring; offsetZ <= ring; offsetZ++) {
boolean edgeZ = offsetZ == -ring || offsetZ == ring;
if (!edgeX && !edgeZ) {
continue;
}
int gridChunkX = centerChunkX + spacing * offsetX;
int gridChunkZ = centerChunkZ + spacing * offsetZ;
ChunkPos candidatePosition = placement.getPotentialStructureChunk(
seed, gridChunkX, gridChunkZ);
Candidate candidate = predictAt(
holders, level, structureManager,
findUnexplored, placement, candidatePosition);
if (candidate != null) {
return candidate;
}
}
}
return null;
}
private static Candidate predictAt(Set<Holder<Structure>> holders,
ServerLevel level,
StructureManager structureManager,
boolean findUnexplored,
StructurePlacement placement,
ChunkPos candidatePosition) {
for (Holder<Structure> holder : holders) {
Structure structure = holder.value();
StructureCheckResult result = structureManager.checkStructurePresence(
candidatePosition, structure, placement, findUnexplored);
if (result == StructureCheckResult.START_NOT_PRESENT) {
continue;
}
if (!findUnexplored && result == StructureCheckResult.START_PRESENT) {
return new Candidate(
Pair.of(placement.getLocatePos(candidatePosition), holder), null);
}
ChunkAccess chunk = level.getChunk(
candidatePosition.x(), candidatePosition.z(), ChunkStatus.STRUCTURE_STARTS);
StructureStart start = structureManager.getStartForStructure(
SectionPos.bottomOf(chunk), structure, chunk);
if (start == null || !start.isValid()
|| findUnexplored && !start.canBeReferenced()) {
continue;
}
return new Candidate(
Pair.of(placement.getLocatePos(start.getChunkPos()), holder),
findUnexplored ? start : null);
}
return null;
}
public static final class Candidate {
private final Pair<BlockPos, Holder<Structure>> result;
private final StructureStart referenceStart;
private final AtomicBoolean committed;
private Candidate(Pair<BlockPos, Holder<Structure>> result,
StructureStart referenceStart) {
this.result = result;
this.referenceStart = referenceStart;
this.committed = new AtomicBoolean();
}
public Pair<BlockPos, Holder<Structure>> result() {
return result;
}
public boolean reference(StructureManager structureManager) {
if (referenceStart == null || !committed.compareAndSet(false, true)) {
return false;
}
if (!referenceStart.canBeReferenced()) {
return false;
}
structureManager.addReference(referenceStart);
return true;
}
}
}
@@ -43,13 +43,17 @@ public final class NativeStructureVegetationClearer {
} }
boolean[] clearColumns = new boolean[area.getXSpan() * area.getZSpan()]; boolean[] clearColumns = new boolean[area.getXSpan() * area.getZSpan()];
for (VegetationTarget target : targets) { for (VegetationTarget target : targets) {
if (target != null && target.force() && target.start() != null && target.start().isValid()) { if (shouldProcessTarget(target)) {
markVegetationColumns(area, snapshot, target, clearColumns); markVegetationColumns(area, snapshot, target, clearColumns);
} }
} }
clearVegetationColumns(world, area, snapshot, clearColumns); clearVegetationColumns(world, area, snapshot, clearColumns);
} }
static boolean shouldProcessTarget(VegetationTarget target) {
return target != null && target.start() != null && target.start().isValid();
}
private static VegetationSnapshot captureVegetation(ChunkAccess chunk, BoundingBox area) { private static VegetationSnapshot captureVegetation(ChunkAccess chunk, BoundingBox area) {
int width = area.getXSpan(); int width = area.getXSpan();
int depth = area.getZSpan(); int depth = area.getZSpan();
@@ -100,9 +104,6 @@ public final class NativeStructureVegetationClearer {
int[] pieceTops = new int[clearColumns.length]; int[] pieceTops = new int[clearColumns.length];
Arrays.fill(pieceTops, Integer.MIN_VALUE); Arrays.fill(pieceTops, Integer.MIN_VALUE);
for (StructurePiece piece : target.start().getPieces()) { for (StructurePiece piece : target.start().getPieces()) {
if (NativeStructureReferenceEnvelope.isMarker(piece)) {
continue;
}
BoundingBox bounds = piece.getBoundingBox(); BoundingBox bounds = piece.getBoundingBox();
int minX = Math.max(area.minX(), bounds.minX()); int minX = Math.max(area.minX(), bounds.minX());
int maxX = Math.min(area.maxX(), bounds.maxX()); int maxX = Math.min(area.maxX(), bounds.maxX());
@@ -15,8 +15,11 @@ import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer; 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.JigsawJunction;
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece; import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece;
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure;
import net.minecraft.world.level.levelgen.structure.structures.JungleTemplePiece; import net.minecraft.world.level.levelgen.structure.structures.JungleTemplePiece;
import net.minecraft.world.level.levelgen.structure.structures.JungleTempleStructure;
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces; import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces;
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -39,11 +42,11 @@ public final class NativeStructureVerticalPlacer {
boolean underground, boolean preserveSourceY, boolean underground, boolean preserveSourceY,
IrisStructureYBand yBand, IrisStructureYBand yBand,
IntBinaryOperator surfaceHeight) { IntBinaryOperator surfaceHeight) {
if (isOceanMonument(structureId)) { if (isOceanMonument(start, structureId)) {
return alignOceanMonumentToSeaLevel( return alignOceanMonumentToSeaLevel(
start, requestedOffset, seaLevel, worldMinY, worldMaxYExclusive); start, requestedOffset, seaLevel, worldMinY, worldMaxYExclusive);
} }
if (isAdjustedScatteredStructure(structureId)) { if (isAdjustedScatteredStructure(start, structureId)) {
return alignScatteredStructureToSurface( return alignScatteredStructureToSurface(
start, structureId, requestedOffset, worldMinY, worldMaxYExclusive, surfaceHeight); start, structureId, requestedOffset, worldMinY, worldMaxYExclusive, surfaceHeight);
} }
@@ -169,18 +172,28 @@ public final class NativeStructureVerticalPlacer {
static void ensureMonumentSeaLevelAlignment(StructureStart start, String structureId, static void ensureMonumentSeaLevelAlignment(StructureStart start, String structureId,
int configuredOffset, int seaLevel, int configuredOffset, int seaLevel,
int worldMinY, int worldMaxYExclusive) { int worldMinY, int worldMaxYExclusive) {
if (isOceanMonument(structureId)) { if (!isOceanMonument(start, structureId)) {
return;
}
synchronized (start) {
alignOceanMonumentToSeaLevel( alignOceanMonumentToSeaLevel(
start, configuredOffset, seaLevel, worldMinY, worldMaxYExclusive); start, configuredOffset, seaLevel, worldMinY, worldMaxYExclusive);
} }
} }
private static boolean isOceanMonument(String structureId) { private static boolean isOceanMonument(StructureStart start, String structureId) {
return OCEAN_MONUMENT_ID.equals(structureId); return OCEAN_MONUMENT_ID.equals(structureId)
&& start != null
&& start.getStructure() instanceof OceanMonumentStructure;
} }
private static boolean isAdjustedScatteredStructure(String structureId) { private static boolean isAdjustedScatteredStructure(StructureStart start, String structureId) {
return DESERT_PYRAMID_ID.equals(structureId) || JUNGLE_PYRAMID_ID.equals(structureId); if (start == null) {
return false;
}
Structure source = start.getStructure();
return (DESERT_PYRAMID_ID.equals(structureId) && source instanceof DesertPyramidStructure)
|| (JUNGLE_PYRAMID_ID.equals(structureId) && source instanceof JungleTempleStructure);
} }
private static ScatteredFeaturePiece requireAdjustedScatteredPiece(StructureStart start, private static ScatteredFeaturePiece requireAdjustedScatteredPiece(StructureStart start,
@@ -252,9 +265,15 @@ public final class NativeStructureVerticalPlacer {
private static OceanMonumentPieces.MonumentBuilding requireOceanMonumentBuilding(StructureStart start) { private static OceanMonumentPieces.MonumentBuilding requireOceanMonumentBuilding(StructureStart start) {
Objects.requireNonNull(start, "Ocean monument start must not be null"); Objects.requireNonNull(start, "Ocean monument start must not be null");
List<StructurePiece> pieces = start.getPieces(); List<StructurePiece> pieces = start.getPieces();
if (pieces.size() != 1 || !(pieces.get(0) instanceof OceanMonumentPieces.MonumentBuilding building)) { OceanMonumentPieces.MonumentBuilding building = null;
for (StructurePiece piece : pieces) {
if (piece instanceof OceanMonumentPieces.MonumentBuilding monumentBuilding) {
building = monumentBuilding;
}
}
if (pieces.size() != 1 || building == null) {
throw new IllegalStateException("minecraft:monument must contain exactly one MonumentBuilding, found " throw new IllegalStateException("minecraft:monument must contain exactly one MonumentBuilding, found "
+ pieces.size() + " top-level pieces"); + pieces.size() + " pieces");
} }
return building; return building;
} }
@@ -27,7 +27,8 @@ import art.arcane.iris.engine.framework.GenerationSessionLease;
import art.arcane.iris.engine.framework.NativeStructureStartPlan; import art.arcane.iris.engine.framework.NativeStructureStartPlan;
import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.nativegen.NativeStructureStartInjector; import art.arcane.iris.nativegen.NativeStructureStartInjector;
import art.arcane.iris.nativegen.NativeStructureLocateResults; import art.arcane.iris.nativegen.NativeStructureReferenceRepair;
import art.arcane.iris.nativegen.NativeStructureVanillaLocator;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBiome;
import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformBlockState;
@@ -294,14 +295,14 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
Engine current = engine(); Engine current = engine();
try (GenerationSessionLease lease = requireGenerationLease(current, "modded_structure_locate"); try (GenerationSessionLease lease = requireGenerationLease(current, "modded_structure_locate");
IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) {
Pair<BlockPos, Holder<Structure>> irisPlaced = nativeStructures.findNearestIrisStructure(
level, holders, pos, Math.max(1, radius), findUnexplored, current);
HolderSet<Structure> reachable = nativeStructures.filterReachableNativeStructures( HolderSet<Structure> reachable = nativeStructures.filterReachableNativeStructures(
level, holders, current); level, holders, current);
Pair<BlockPos, Holder<Structure>> nativeLocated = reachable.size() == 0 NativeStructureVanillaLocator.Candidate nativeCandidate =
? null reachable.size() == 0 ? null
: super.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); : NativeStructureVanillaLocator.predict(
return NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated); level, reachable, pos, radius, findUnexplored);
return nativeStructures.findNearestIrisStructure(
level, holders, pos, Math.max(0, radius), findUnexplored, current, nativeCandidate);
} }
} }
@@ -879,7 +880,8 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
Engine current = engine(); Engine current = engine();
try (GenerationSessionLease lease = requireGenerationLease(current, "modded_create_references"); try (GenerationSessionLease lease = requireGenerationLease(current, "modded_create_references");
IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) {
super.createReferences(level, structureManager, chunk); NativeStructureReferenceRepair.createReferences(
current, level, structureManager, chunk);
} }
} }
@@ -23,7 +23,7 @@ import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.RuntimeUiMessages; import art.arcane.iris.core.localization.RuntimeUiMessages;
import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.nms.datapack.IDataFixer; import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator; import art.arcane.iris.core.pack.PackValidator;
import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimension;
@@ -74,7 +74,6 @@ public final class ModdedForcedDatapack {
// v2: custom biomes now inherit their vanilla derivative's biome tags, so every already-published pack has // v2: custom biomes now inherit their vanilla derivative's biome tags, so every already-published pack has
// to regenerate once. // to regenerate once.
private static final String HASH_SALT = "iris-forced-datapack-v2"; private static final String HASH_SALT = "iris-forced-datapack-v2";
private static final String GIT_DIRECTORY = ".git";
private static final long PACKS_HASH_TTL_NANOS = 2_000_000_000L; private static final long PACKS_HASH_TTL_NANOS = 2_000_000_000L;
private static final Object LOCK = new Object(); private static final Object LOCK = new Object();
private static final AtomicBoolean LOADED = new AtomicBoolean(false); private static final AtomicBoolean LOADED = new AtomicBoolean(false);
@@ -147,13 +146,13 @@ public final class ModdedForcedDatapack {
return; return;
} }
Path packsRoot = packsRoot(); Path packsRoot = packsRoot();
File[] packs = packsRoot.toFile().listFiles(File::isDirectory); List<File> packs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot.toFile());
if (packs == null || packs.length == 0) { if (packs.isEmpty()) {
return; return;
} }
LOGGER.error("==============================================================="); LOGGER.error("===============================================================");
LOGGER.error("Iris forced datapack '{}' was never loaded by this server.", PACK_ID); LOGGER.error("Iris forced datapack '{}' was never loaded by this server.", PACK_ID);
LOGGER.error("{} installed pack(s) at {} contributed no dimension types or custom biomes.", packs.length, packsRoot); LOGGER.error("{} installed pack(s) at {} contributed no dimension types or custom biomes.", packs.size(), packsRoot);
LOGGER.error("Datapack source injection failed for this loader (mixin/event not applied), so world creation will fail and restarting will not fix it."); LOGGER.error("Datapack source injection failed for this loader (mixin/event not applied), so world creation will fail and restarting will not fix it.");
LOGGER.error("==============================================================="); LOGGER.error("===============================================================");
} }
@@ -360,8 +359,8 @@ public final class ModdedForcedDatapack {
Files.walkFileTree(root, new SimpleFileVisitor<Path>() { Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
@Override @Override
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) { public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
// Studio packs can be git checkouts; .git churns constantly and never reaches the datapack. return !directory.equals(root)
return GIT_DIRECTORY.equals(directory.getFileName().toString()) && PackDirectoryResolver.isHiddenName(directory.getFileName().toString())
? FileVisitResult.SKIP_SUBTREE ? FileVisitResult.SKIP_SUBTREE
: FileVisitResult.CONTINUE; : FileVisitResult.CONTINUE;
} }
@@ -397,16 +396,10 @@ public final class ModdedForcedDatapack {
int packCount = 0; int packCount = 0;
KList<String> presetIds = new KList<>(); KList<String> presetIds = new KList<>();
File root = packsRoot().toFile(); File root = packsRoot().toFile();
File[] packs = root.listFiles(File::isDirectory); List<File> packs = PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(root);
if (packs == null && root.exists()) { for (File pack : packs) {
throw new IOException("Iris could not read installed pack directory " + root.getAbsolutePath()); if (stagePack(pack, fixer, stagingDirectory, seenBiomes, presetIds)) {
} packCount++;
if (packs != null) {
Arrays.sort(packs, Comparator.comparing(File::getName));
for (File pack : packs) {
if (stagePack(pack, fixer, stagingDirectory, seenBiomes, presetIds)) {
packCount++;
}
} }
} }
@@ -431,8 +424,7 @@ public final class ModdedForcedDatapack {
KList<String> presetIds) throws IOException { KList<String> presetIds) throws IOException {
PackValidationResult validation; PackValidationResult validation;
try { try {
validation = PackValidator.validate(sourcePack); validation = PackValidator.validateForDatapackBootstrap(sourcePack);
PackValidationRegistry.publish(validation);
} catch (Throwable validationFailure) { } catch (Throwable validationFailure) {
LOGGER.error("Iris excluded pack '{}' from Create World because validation failed", LOGGER.error("Iris excluded pack '{}' from Create World because validation failed",
sourcePack.getName(), validationFailure); sourcePack.getName(), validationFailure);
@@ -21,17 +21,21 @@ package art.arcane.iris.modded;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.IrisStructureLocator;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner; import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
import art.arcane.iris.engine.framework.NativeStructureStartPlan; import art.arcane.iris.engine.framework.NativeStructureStartPlan;
import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisMaterialPalette;
import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisNativeStructureDecision;
import art.arcane.iris.nativegen.NativeStructureGenerationException; import art.arcane.iris.nativegen.NativeStructureGenerationException;
import art.arcane.iris.nativegen.NativeStructureLocatePersistence;
import art.arcane.iris.nativegen.NativeStructureLocateResults;
import art.arcane.iris.nativegen.NativeStructureOwnershipRecovery;
import art.arcane.iris.nativegen.NativeStructurePostProcessor; import art.arcane.iris.nativegen.NativeStructurePostProcessor;
import art.arcane.iris.nativegen.NativeStructureReferenceEnvelope; import art.arcane.iris.nativegen.NativeStructureReferenceEnvelope;
import art.arcane.iris.nativegen.NativeStructureSurfaceFitter; import art.arcane.iris.nativegen.NativeStructureSurfaceFitter;
import art.arcane.iris.nativegen.NativeStructureTerrainIntegrator; import art.arcane.iris.nativegen.NativeStructureTerrainIntegrator;
import art.arcane.iris.nativegen.NativeStructureVegetationClearer; import art.arcane.iris.nativegen.NativeStructureVegetationClearer;
import art.arcane.iris.nativegen.NativeStructureVerticalPlacer; import art.arcane.iris.nativegen.NativeStructureVerticalPlacer;
import art.arcane.iris.nativegen.NativeStructureVanillaLocator;
import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps; import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps;
import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.RNG;
@@ -60,6 +64,7 @@ import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -85,42 +90,85 @@ final class ModdedNativeStructureStage {
Pair<BlockPos, Holder<Structure>> findNearestIrisStructure(ServerLevel level, Pair<BlockPos, Holder<Structure>> findNearestIrisStructure(ServerLevel level,
HolderSet<Structure> holders, HolderSet<Structure> holders,
BlockPos pos, int radius, boolean findUnexplored, BlockPos pos, int radius, boolean findUnexplored,
Engine current) { Engine current,
if (findUnexplored) { NativeStructureVanillaLocator.Candidate nativeCandidate) {
return null; Pair<BlockPos, Holder<Structure>> nativeLocated =
} nativeCandidate == null ? null : nativeCandidate.result();
Runnable nativeReference = () -> {
if (nativeCandidate != null) {
nativeCandidate.reference(level.structureManager());
}
};
Registry<Structure> registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); Registry<Structure> registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
BlockPos best = null; List<IrisNativeLocateSearch> searches = new ArrayList<>(holders.size());
Holder<Structure> bestHolder = null; NativeStructureLocatePersistence.ProbeBudget budget = NativeStructureLocatePersistence.probeBudget();
long bestDistance = Long.MAX_VALUE;
for (Holder<Structure> holder : holders) { for (Holder<Structure> holder : holders) {
Identifier id = registry.getKey(holder.value()); Identifier id = registry.getKey(holder.value());
if (id == null) { if (id == null) {
throw new IllegalStateException("Native structure locate received an unregistered structure holder"); throw new IllegalStateException("Native structure locate received an unregistered structure holder");
} }
String structureId = id.toString(); String structureId = id.toString();
if (!IrisStructureLocator.isPlaced(current, structureId)) { if (!IrisStructureLocator.hasNativePlacement(current, structureId)) {
continue; continue;
} }
IrisStructureLocator.LocateResult result = IrisStructureLocator.locate( NativeStructureLocatePersistence.Probe probe = NativeStructureLocatePersistence.probe(
current, structureId, pos.getX(), pos.getZ(), radius); level, holder.value(), findUnexplored, budget);
if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { searches.add(new IrisNativeLocateSearch(
throw new IllegalStateException("Iris structure locate reached its safety limit for " holder, structureId, NativeStructureLocatePersistence.search(
+ structureId + " within " + radius + " chunks"); current, structureId, pos.getX(), pos.getZ(), radius, probe)));
}
if (!result.found()) {
continue;
}
long dx = (long) result.originX() - pos.getX();
long dz = (long) result.originZ() - pos.getZ();
long distance = dx * dx + dz * dz;
if (distance < bestDistance) {
bestDistance = distance;
best = new BlockPos(result.originX(), result.baseY(), result.originZ());
bestHolder = holder;
}
} }
return best == null ? null : Pair.of(best, bestHolder); searches.sort(Comparator.comparing(IrisNativeLocateSearch::structureId));
for (int attempt = 0; attempt < NativeStructureLocatePersistence.MAX_SELECTED_CANDIDATE_RETRIES; attempt++) {
IrisNativeLocateSearch bestSearch = null;
IrisStructureLocator.LocateResult bestResult = null;
long bestDistance = Long.MAX_VALUE;
for (IrisNativeLocateSearch search : searches) {
IrisStructureLocator.LocateResult result = search.search().predict();
if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) {
throw new IllegalStateException("Iris structure locate reached its safety limit for "
+ search.structureId() + " within " + radius + " placement rings");
}
if (!result.found()) {
continue;
}
long dx = (long) result.originX() - pos.getX();
long dz = (long) result.originZ() - pos.getZ();
long distance = dx * dx + dz * dz;
if (distance < bestDistance) {
bestDistance = distance;
bestSearch = search;
bestResult = result;
}
}
if (bestSearch == null) {
return NativeStructureLocateResults.selectAndReference(
pos, null, () -> { }, nativeLocated, nativeReference);
}
Pair<BlockPos, Holder<Structure>> predicted = Pair.of(
new BlockPos(bestResult.originX(), bestResult.baseY(), bestResult.originZ()),
bestSearch.holder());
if (NativeStructureLocateResults.nearest(pos, predicted, nativeLocated) != predicted) {
return NativeStructureLocateResults.selectAndReference(
pos, predicted, () -> { }, nativeLocated, nativeReference);
}
NativeStructureLocatePersistence.VerifiedStart verified =
bestSearch.search().verify(bestResult);
if (verified == null) {
bestSearch.search().reject(bestResult);
continue;
}
BlockPos located = new BlockPos(
bestResult.originX(), verified.ownership().locatorY(),
bestResult.originZ());
Pair<BlockPos, Holder<Structure>> irisLocated = Pair.of(located, bestSearch.holder());
IrisNativeLocateSearch selectedSearch = bestSearch;
NativeStructureLocatePersistence.VerifiedStart selectedStart = verified;
return NativeStructureLocateResults.selectAndReference(
pos, irisLocated, () -> selectedSearch.search().reference(selectedStart),
nativeLocated, nativeReference);
}
throw new IllegalStateException("Iris structure locate rejected too many selected candidates within "
+ radius + " placement rings");
} }
HolderSet<Structure> filterReachableNativeStructures(ServerLevel level, HolderSet<Structure> holders, HolderSet<Structure> filterReachableNativeStructures(ServerLevel level, HolderSet<Structure> holders,
@@ -193,10 +241,14 @@ final class ModdedNativeStructureStage {
decision.preserveSourceY(), decision.preserveSourceY(),
decision.yBand(), decision.yBand(),
(x, z) -> current.getHeight(x, z, true) + current.getMinHeight()); (x, z) -> current.getHeight(x, z, true) + current.getMinHeight());
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap( StructureStart wrapped = NativeStructureReferenceEnvelope.wrapForPublication(
start, structure, start.getReferences(), templateManager, start, structure, start.getReferences(),
NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain())); NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain()),
structureId);
chunk.setStartForStructure(structure, wrapped); chunk.setStartForStructure(structure, wrapped);
if (!wrapped.isValid()) {
continue;
}
} catch (Throwable error) { } catch (Throwable error) {
throw NativeStructureGenerationException.failure( throw NativeStructureGenerationException.failure(
"vertical adjustment", structureId, chunkPos.x(), chunkPos.z(), error); "vertical adjustment", structureId, chunkPos.x(), chunkPos.z(), error);
@@ -227,7 +279,6 @@ final class ModdedNativeStructureStage {
Engine current = generator.engine(); Engine current = generator.engine();
List<NativePlacementGroup> placementGroups = new ArrayList<>(); List<NativePlacementGroup> placementGroups = new ArrayList<>();
List<StructureStart> heightmapStarts = new ArrayList<>(); List<StructureStart> heightmapStarts = new ArrayList<>();
List<StructureStart> nativeStarts = new ArrayList<>();
List<NativeStructureVegetationClearer.VegetationTarget> vegetationTargets = new ArrayList<>(); List<NativeStructureVegetationClearer.VegetationTarget> vegetationTargets = new ArrayList<>();
List<NativeStructureTerrainIntegrator.TerrainTarget> terrainTargets = new ArrayList<>(); List<NativeStructureTerrainIntegrator.TerrainTarget> terrainTargets = new ArrayList<>();
for (int step = 0; step < steps; step++) { for (int step = 0; step < steps; step++) {
@@ -245,10 +296,11 @@ final class ModdedNativeStructureStage {
List<StructureStart> starts = structureManager.startsForStructure(sectionPos, structure); List<StructureStart> starts = structureManager.startsForStructure(sectionPos, structure);
List<NativePlacement> resolvedPlacements = new ArrayList<>(starts.size()); List<NativePlacement> resolvedPlacements = new ArrayList<>(starts.size());
for (StructureStart start : starts) { for (StructureStart start : starts) {
NativeStructureStartPlan plan = NativeStructurePlacementPlanner.matchingPlan( NativeStructureOwnershipRecord ownership =
current, structureId, start.getChunkPos().x(), start.getChunkPos().z()); NativeStructureOwnershipRecovery.resolve(
IrisNativeStructureDecision decision = plan == null current, world.getLevel(), structureId, structure, start);
? sourceDecision : NativeStructurePlacementPlanner.decisionFor(plan); IrisNativeStructureDecision decision =
ownership == null ? sourceDecision : ownership.restoredDecision();
if (!decision.generate()) { if (!decision.generate()) {
continue; continue;
} }
@@ -258,9 +310,6 @@ final class ModdedNativeStructureStage {
structureId, start, structureId, start,
NativeStructureTerrainIntegrator.resolveNativeTerrain( NativeStructureTerrainIntegrator.resolveNativeTerrain(
start, decision.terrain()))); start, decision.terrain())));
if (plan == null || !plan.placement().isUnderground()) {
nativeStarts.add(start);
}
boolean clearEntireFootprint = NativeStructureVegetationClearer boolean clearEntireFootprint = NativeStructureVegetationClearer
.shouldClearEntireVegetationFootprint( .shouldClearEntireVegetationFootprint(
structure.step(), decision.clearVegetation()); structure.step(), decision.clearVegetation());
@@ -289,15 +338,6 @@ final class ModdedNativeStructureStage {
"heightmap priming", nativeStructureBatchContext(placementGroups), "heightmap priming", nativeStructureBatchContext(placementGroups),
chunkPos.x(), chunkPos.z(), error); chunkPos.x(), chunkPos.z(), error);
} }
try {
NativeStructureSurfaceFitter.prepareSurfaceStructures(
world, area, nativeStarts,
(x, z) -> current.getHeight(x, z, true) + current.getMinHeight());
} catch (Throwable error) {
throw NativeStructureGenerationException.failure(
"terrain integration", nativeStructureBatchContext(placementGroups),
chunkPos.x(), chunkPos.z(), error);
}
try { try {
NativeStructureVegetationClearer.clearIntersectingVegetation( NativeStructureVegetationClearer.clearIntersectingVegetation(
world, chunk, area, vegetationTargets); world, chunk, area, vegetationTargets);
@@ -306,6 +346,15 @@ final class ModdedNativeStructureStage {
"vegetation cleanup", nativeStructureBatchContext(placementGroups), "vegetation cleanup", nativeStructureBatchContext(placementGroups),
chunkPos.x(), chunkPos.z(), error); chunkPos.x(), chunkPos.z(), error);
} }
try {
NativeStructureSurfaceFitter.prepareSurfaceStructures(
world, area, terrainTargets,
(x, z) -> current.getHeight(x, z, true) + current.getMinHeight());
} catch (Throwable error) {
throw NativeStructureGenerationException.failure(
"terrain integration", nativeStructureBatchContext(placementGroups),
chunkPos.x(), chunkPos.z(), error);
}
try { try {
NativeStructurePostProcessor.prepareTerrain( NativeStructurePostProcessor.prepareTerrain(
world, area, terrainTargets, this::resolvePaletteBlock); world, area, terrainTargets, this::resolvePaletteBlock);
@@ -413,8 +462,8 @@ final class ModdedNativeStructureStage {
ChunkPos chunkPos = chunk.getPos(); ChunkPos chunkPos = chunk.getPos();
int minX = chunkPos.getMinBlockX(); int minX = chunkPos.getMinBlockX();
int minZ = chunkPos.getMinBlockZ(); int minZ = chunkPos.getMinBlockZ();
int minY = chunk.getMinY(); int minY = chunk.getMinY() + 1;
int maxY = minY + chunk.getHeight() - 1; int maxY = chunk.getMinY() + chunk.getHeight() - 1;
return new BoundingBox(minX, minY, minZ, minX + 15, maxY, minZ + 15); return new BoundingBox(minX, minY, minZ, minX + 15, maxY, minZ + 15);
} }
@@ -436,6 +485,10 @@ final class ModdedNativeStructureStage {
List<NativePlacement> placements) { List<NativePlacement> placements) {
} }
private record IrisNativeLocateSearch(Holder<Structure> holder, String structureId,
NativeStructureLocatePersistence.Search search) {
}
private record StructureStepCache(Registry<Structure> registry, List<List<Structure>> structures) { private record StructureStepCache(Registry<Structure> registry, List<List<Structure>> structures) {
} }
} }
@@ -56,8 +56,8 @@ public final class ModdedPackInstaller {
synchronized (installLock) { synchronized (installLock) {
File packs = configDir.resolve("irisworldgen").resolve("packs").toFile(); File packs = configDir.resolve("irisworldgen").resolve("packs").toFile();
try { try {
boolean installed = PackDownloader.isDefaultOverworld(pack) PackDownloader.PackInstallResult result = PackDownloader.isDefaultOverworld(pack)
? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback) != null ? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback)
: PackDownloader.download( : PackDownloader.download(
packs, packs,
"IrisDimensions/" + pack, "IrisDimensions/" + pack,
@@ -65,8 +65,9 @@ public final class ModdedPackInstaller {
forceOverwrite, forceOverwrite,
false, false,
pack, pack,
feedback) != null; feedback);
if (installed) { boolean installed = result != null;
if (result != null && result.changed()) {
// Pack-install completion is one of the four forced-datapack regeneration triggers; every // Pack-install completion is one of the four forced-datapack regeneration triggers; every
// install call site already runs off the server thread, so regenerate inline here. A // install call site already runs off the server thread, so regenerate inline here. A
// regeneration failure must never turn a successful install into a failed one. // regeneration failure must never turn a successful install into a failed one.
@@ -76,6 +77,9 @@ public final class ModdedPackInstaller {
LOGGER.error("Iris installed pack '{}' but could not regenerate the forced datapack", pack, regenerationFailure); LOGGER.error("Iris installed pack '{}' but could not regenerate the forced datapack", pack, regenerationFailure);
} }
} }
if (result != null && result.restartRequired()) {
feedback.accept("Pack '" + pack + "' is installed on disk and requires a server restart before its active data changes.");
}
return installed; return installed;
} catch (IOException error) { } catch (IOException error) {
LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error); LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error);
@@ -28,6 +28,7 @@ import art.arcane.iris.spi.PlatformEntityType;
import art.arcane.iris.spi.PlatformItem; import art.arcane.iris.spi.PlatformItem;
import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.volmlib.util.data.UnresolvedKeyLog; import art.arcane.volmlib.util.data.UnresolvedKeyLog;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.Registry; import net.minecraft.core.Registry;
import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
@@ -40,6 +41,7 @@ import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.Property; import net.minecraft.world.level.block.state.properties.Property;
import net.minecraft.world.level.storage.loot.LootTable;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -212,6 +214,20 @@ public final class ModdedRegistries implements PlatformRegistries {
return keys; return keys;
} }
@Override
public List<String> lootTableKeys() {
List<String> keys = new ArrayList<>();
MinecraftServer instance = server.get();
if (instance == null) {
warnNotReady("loot table");
return keys;
}
HolderLookup.RegistryLookup<LootTable> registry = instance.reloadableRegistries().lookup()
.lookupOrThrow(Registries.LOOT_TABLE);
registry.listElementIds().forEach(key -> keys.add(key.identifier().toString()));
return keys;
}
@Override @Override
public Map<String, List<PlatformBlockProperty>> blockStateProperties() { public Map<String, List<PlatformBlockProperty>> blockStateProperties() {
Map<String, List<PlatformBlockProperty>> properties = new LinkedHashMap<>(); Map<String, List<PlatformBlockProperty>> properties = new LinkedHashMap<>();
@@ -19,6 +19,7 @@
package art.arcane.iris.modded; package art.arcane.iris.modded;
import art.arcane.iris.core.pack.BrokenPackException; import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidationResult;
@@ -104,9 +105,9 @@ public final class ModdedStartup {
public static void validateAllPacks() { public static void validateAllPacks() {
File packsRoot = ModdedPackCommands.packsRoot(); File packsRoot = ModdedPackCommands.packsRoot();
File[] packDirs = packsRoot.listFiles(File::isDirectory); List<File> packDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
PackValidationRegistry.clear(); PackValidationRegistry.clear();
if (packDirs == null || packDirs.length == 0) { if (packDirs.isEmpty()) {
LOGGER.info("Iris found no packs to validate under {}; install one with /iris download <pack>", LOGGER.info("Iris found no packs to validate under {}; install one with /iris download <pack>",
packsRoot.getAbsolutePath()); packsRoot.getAbsolutePath());
return; return;
@@ -146,8 +147,8 @@ public final class ModdedStartup {
if (pack == null || pack.isBlank()) { if (pack == null || pack.isBlank()) {
throw new IllegalArgumentException("Pack name is required for world creation"); throw new IllegalArgumentException("Pack name is required for world creation");
} }
File packDir = new File(ModdedPackCommands.packsRoot(), pack); File packDir = PackDirectoryResolver.resolveExisting(ModdedPackCommands.packsRoot(), pack);
if (!packDir.isDirectory()) { if (packDir == null) {
throw new BrokenPackException(pack, List.of( throw new BrokenPackException(pack, List.of(
"Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + ".")); "Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + "."));
} }
@@ -19,8 +19,10 @@
package art.arcane.iris.modded; package art.arcane.iris.modded;
import art.arcane.iris.nativegen.NativeStructureGenerationException; import art.arcane.iris.nativegen.NativeStructureGenerationException;
import art.arcane.iris.nativegen.NativeStructureFactory;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.iris.spi.PlatformStructureHooks;
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
import art.arcane.iris.spi.PlatformWorld; import art.arcane.iris.spi.PlatformWorld;
import net.minecraft.core.BlockPos; import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder; import net.minecraft.core.Holder;
@@ -95,6 +97,67 @@ public final class ModdedStructureHooks implements PlatformStructureHooks {
return registryKeys(Registries.TEMPLATE_POOL); return registryKeys(Registries.TEMPLATE_POOL);
} }
@Override
public JigsawSourceMetadata jigsawSourceMetadata(String structureKey) {
MinecraftServer instance = requireServer("resolve live jigsaw metadata for registered structure '"
+ structureKey + "'");
try {
Identifier identifier = Identifier.tryParse(structureKey);
if (identifier == null) {
throw new IllegalArgumentException("Invalid registered structure key: " + structureKey);
}
Registry<Structure> registry = instance.registryAccess().lookupOrThrow(Registries.STRUCTURE);
Structure structure = registry.getValue(identifier);
if (structure == null) {
throw new IllegalArgumentException("Registered structure does not exist: " + structureKey);
}
if (!(structure instanceof JigsawStructure jigsaw)) {
throw new IllegalArgumentException("Registered structure is not a jigsaw: " + structureKey);
}
return NativeStructureFactory.sourceMetadata(
instance.registryAccess(), instance.getStructureManager(), jigsaw);
} catch (RuntimeException error) {
throw new IllegalStateException("Iris failed to resolve live jigsaw metadata for registered structure '"
+ structureKey + "' from the modded structure registry", error);
}
}
@Override
public int templatePoolHorizontalSpan(String templatePoolKey) {
MinecraftServer instance = requireServer("resolve the live horizontal span for registered template pool '"
+ templatePoolKey + "'");
try {
return NativeStructureFactory.templatePoolHorizontalSpan(
instance.registryAccess(), instance.getStructureManager(), templatePoolKey);
} catch (RuntimeException error) {
throw new IllegalStateException("Iris failed to resolve the live horizontal span for registered "
+ "template pool '" + templatePoolKey + "' from the modded template-pool registry", error);
}
}
@Override
public int jigsawStartPoolHorizontalSpan(String structureKey, String templatePoolKey) {
MinecraftServer instance = requireServer("resolve the effective start-pool span for registered jigsaw '"
+ structureKey + "'");
try {
Identifier identifier = Identifier.tryParse(structureKey);
if (identifier == null) {
throw new IllegalArgumentException("Invalid registered structure key: " + structureKey);
}
Structure structure = instance.registryAccess().lookupOrThrow(Registries.STRUCTURE)
.getValue(identifier);
if (!(structure instanceof JigsawStructure jigsaw)) {
throw new IllegalArgumentException("Registered structure is not a jigsaw: " + structureKey);
}
return NativeStructureFactory.jigsawStartPoolHorizontalSpan(
instance.registryAccess(), instance.getStructureManager(), jigsaw, templatePoolKey);
} catch (RuntimeException error) {
throw new IllegalStateException("Iris failed to resolve the effective start-pool span for registered "
+ "jigsaw structure '" + structureKey + "' and pool '" + templatePoolKey
+ "' from the modded registries", error);
}
}
@Override @Override
public List<String> structureSetKeys() { public List<String> structureSetKeys() {
return registryKeys(Registries.STRUCTURE_SET); return registryKeys(Registries.STRUCTURE_SET);
@@ -18,6 +18,7 @@
package art.arcane.iris.modded.command; package art.arcane.iris.modded.command;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.IrisStructureLocator;
import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.IrisModdedChunkGenerator;
@@ -153,24 +154,18 @@ final class ModdedCommandSuggestions {
names.add("overworld"); names.add("overworld");
try { try {
File packs = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs").toFile(); File packs = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs").toFile();
File[] children = packs.listFiles(); for (File child : PackDirectoryResolver.listVisiblePackDirectories(packs)) {
if (children != null) { String packName = child.getName();
for (File child : children) { names.add(packName);
if (!child.isDirectory()) { File dimensions = new File(child, "dimensions");
continue; File[] dimensionFiles = dimensions.listFiles(
} (File directory, String name) -> name.endsWith(".json"));
String packName = child.getName(); if (dimensionFiles == null) {
names.add(packName); continue;
File dimensions = new File(child, "dimensions"); }
File[] dimensionFiles = dimensions.listFiles( for (File dimensionFile : dimensionFiles) {
(File directory, String name) -> name.endsWith(".json")); String fileName = dimensionFile.getName();
if (dimensionFiles == null) { names.add(packName + ":" + fileName.substring(0, fileName.length() - 5));
continue;
}
for (File dimensionFile : dimensionFiles) {
String fileName = dimensionFile.getName();
names.add(packName + ":" + fileName.substring(0, fileName.length() - 5));
}
} }
} }
} catch (Throwable e) { } catch (Throwable e) {
@@ -20,6 +20,7 @@ package art.arcane.iris.modded.command;
import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.IrisModdedChunkGenerator;
@@ -42,8 +43,12 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.Predicate; import java.util.function.Predicate;
import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.IrisLanguage;
@@ -74,11 +79,11 @@ public final class ModdedDatapackCommands {
root.then(Commands.literal("ls") root.then(Commands.literal("ls")
.executes((CommandContext<CommandSourceStack> context) -> list(context.getSource()))); .executes((CommandContext<CommandSourceStack> context) -> list(context.getSource())));
root.then(message("ingest", "Modrinth datapack ingest requires the Bukkit plugin because its editable-resource import and manifest workflow use Bukkit tooling. Iris modded dimensions do run native vanilla and datapack structure placement; install the datapack in world/datapacks and restart to generate its registered structures.")); root.then(message("ingest", "Managed Modrinth datapack ingest is Bukkit-only. On modded servers install the datapack folder or zip in world/datapacks, enable it, restart, then use /iris datapack list to confirm it is enabled. Registered structures generate natively in Iris dimensions."));
root.then(message("pull", "Modrinth datapack ingest requires the Bukkit plugin because its editable-resource import and manifest workflow use Bukkit tooling. Iris modded dimensions do run native vanilla and datapack structure placement; install the datapack in world/datapacks and restart to generate its registered structures.")); root.then(message("pull", "Managed Modrinth datapack ingest is Bukkit-only. On modded servers install the datapack folder or zip in world/datapacks, enable it, restart, then use /iris datapack list to confirm it is enabled. Registered structures generate natively in Iris dimensions."));
root.then(message("remove", "Datapack removal manages the Bukkit ingest manifest. On modded servers delete the datapack folder from world/datapacks and restart.")); root.then(message("remove", "Managed datapack removal is Bukkit-only. On modded servers disable the pack, delete its folder or zip from world/datapacks, and restart."));
root.then(message("rm", "Datapack removal manages the Bukkit ingest manifest. On modded servers delete the datapack folder from world/datapacks and restart.")); root.then(message("rm", "Managed datapack removal is Bukkit-only. On modded servers disable the pack, delete its folder or zip from world/datapacks, and restart."));
return root; return root;
} }
@@ -215,27 +220,24 @@ public final class ModdedDatapackCommands {
MinecraftServer server = source.getServer(); MinecraftServer server = source.getServer();
LinkedHashSet<String> configured = new LinkedHashSet<>(); LinkedHashSet<String> configured = new LinkedHashSet<>();
File packsRoot = ModdedPackCommands.packsRoot(); File packsRoot = ModdedPackCommands.packsRoot();
File[] packs = packsRoot.isDirectory() ? packsRoot.listFiles(File::isDirectory) : null; for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsRoot)) {
if (packs != null) { if (!new File(pack, "dimensions").isDirectory()) {
for (File pack : packs) { continue;
if (!new File(pack, "dimensions").isDirectory()) { }
continue; try {
} IrisData data = IrisData.get(pack);
try { for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) {
IrisData data = IrisData.get(pack); if (dimension == null || dimension.getDatapackImports() == null) {
for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) { continue;
if (dimension == null || dimension.getDatapackImports() == null) { }
continue; for (String url : dimension.getDatapackImports()) {
} if (url != null && !url.isBlank()) {
for (String url : dimension.getDatapackImports()) { configured.add(url.trim());
if (url != null && !url.isBlank()) {
configured.add(url.trim());
}
} }
} }
} catch (Throwable e) {
LOGGER.error("Iris datapack import scan failed for pack {}", pack.getName(), e);
} }
} catch (Throwable e) {
LOGGER.error("Iris datapack import scan failed for pack {}", pack.getName(), e);
} }
} }
@@ -248,19 +250,45 @@ public final class ModdedDatapackCommands {
} }
File datapacks = worldDatapacksFolder(server); File datapacks = worldDatapacksFolder(server);
File[] installed = datapacks.isDirectory() ? datapacks.listFiles(File::isDirectory) : null; File[] installed = datapacks.isDirectory()
? datapacks.listFiles(file -> file.isDirectory() || file.isFile() && file.getName().toLowerCase(Locale.ROOT).endsWith(".zip"))
: null;
Set<String> availableIds = new HashSet<>(server.getPackRepository().getAvailableIds());
Set<String> selectedIds = new HashSet<>(server.getPackRepository().getSelectedIds());
KList<String> names = new KList<>(); KList<String> names = new KList<>();
if (installed != null) { if (installed != null) {
for (File folder : installed) { for (File installedPack : installed) {
if (new File(folder, "pack.mcmeta").isFile()) { String name = installedPack.getName();
names.add(folder.getName()); String repositoryId = resolveRepositoryId(name, availableIds);
String state;
if (repositoryId == null) {
state = "unavailable";
} else if (selectedIds.contains(repositoryId)) {
state = "enabled";
} else {
state = "disabled";
} }
names.add(name + " [" + state + "]");
} }
} }
Collections.sort(names);
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_DATAPACK_COMMANDS_INSTALLED_WORLD_DATAPACKS, MessageArgument.untrusted("value", names.size()))); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_DATAPACK_COMMANDS_INSTALLED_WORLD_DATAPACKS, MessageArgument.untrusted("value", names.size())));
for (String name : names) { for (String name : names) {
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_DATAPACK_COMMANDS_MESSAGE_2, MessageArgument.untrusted("name", name))); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_DATAPACK_COMMANDS_MESSAGE_2, MessageArgument.untrusted("name", name)));
} }
return 1; return 1;
} }
private static String resolveRepositoryId(String filename, Set<String> availableIds) {
String direct = "file/" + filename;
if (availableIds.contains(direct)) {
return direct;
}
for (String availableId : availableIds) {
if (availableId.equals(filename) || availableId.endsWith("/" + filename)) {
return availableId;
}
}
return null;
}
} }
@@ -173,10 +173,16 @@ final class ModdedLocateCommands {
return 0; return 0;
} }
if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) {
locateIrisStructure(source, level, engine, player, target.key()); if (!IrisStructureLocator.hasNativePlacement(engine, target.key())) {
locateIrisStructure(source, level, engine, player, target.key());
return 1;
}
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_SEARCHING_NATIVE_STRUCTURE_WITHIN_CHUNKS, MessageArgument.untrusted("value", target.key()), MessageArgument.untrusted("NATIVESTRUCTURELOCATERADIUS", NATIVE_STRUCTURE_LOCATE_RADIUS)));
runNativeStructureLocate(source, level, player, target);
return 1; return 1;
} }
if (target.availability() != NativeStructureAvailability.AVAILABLE) { if (!IrisStructureLocator.hasNativePlacement(engine, target.key())
&& target.availability() != NativeStructureAvailability.AVAILABLE) {
IrisModdedCommands.fail(source, nativeUnavailableMessage(target.key(), target.availability())); IrisModdedCommands.fail(source, nativeUnavailableMessage(target.key(), target.availability()));
return 0; return 0;
} }
@@ -110,14 +110,12 @@ public final class ModdedPackCommands {
List<File> targets = new ArrayList<>(); List<File> targets = new ArrayList<>();
if (pack == null || pack.isBlank()) { if (pack == null || pack.isBlank()) {
File[] dirs = packsRoot.listFiles(File::isDirectory); List<File> dirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
if (dirs == null || dirs.length == 0) { if (dirs.isEmpty()) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_PACK_COMMANDS_NO_PACKS_VALIDATE_UNDER, MessageArgument.untrusted("value", packsRoot.getAbsolutePath()))); IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_PACK_COMMANDS_NO_PACKS_VALIDATE_UNDER, MessageArgument.untrusted("value", packsRoot.getAbsolutePath())));
return 0; return 0;
} }
for (File dir : dirs) { targets.addAll(dirs);
targets.add(dir);
}
} else { } else {
File target = PackDirectoryResolver.resolveExisting(packsRoot, pack); File target = PackDirectoryResolver.resolveExisting(packsRoot, pack);
if (target == null) { if (target == null) {
@@ -48,7 +48,8 @@ public class ModdedGenerationLeaseContractTest {
String references = method(source, "public void createReferences("); String references = method(source, "public void createReferences(");
assertTrue(references.contains("requireGenerationLease(current, \"modded_create_references\")")); assertTrue(references.contains("requireGenerationLease(current, \"modded_create_references\")"));
assertTrue(references.contains("IrisContext.open(current, lease.sessionId(), null)")); assertTrue(references.contains("IrisContext.open(current, lease.sessionId(), null)"));
assertTrue(references.contains("super.createReferences(level, structureManager, chunk);")); assertTrue(references.contains("NativeStructureReferenceRepair.createReferences("));
assertFalse(references.contains("super.createReferences(level, structureManager, chunk);"));
} }
@Test @Test
@@ -80,6 +80,11 @@ public class ModdedStructureHooksTest {
IllegalStateException.class, hooks::jigsawStructureKeys); IllegalStateException.class, hooks::jigsawStructureKeys);
IllegalStateException templatePoolKeys = assertThrows( IllegalStateException templatePoolKeys = assertThrows(
IllegalStateException.class, hooks::templatePoolKeys); IllegalStateException.class, hooks::templatePoolKeys);
IllegalStateException jigsawMetadata = assertThrows(
IllegalStateException.class, () -> hooks.jigsawSourceMetadata("minecraft:village"));
IllegalStateException templatePoolSpan = assertThrows(
IllegalStateException.class,
() -> hooks.templatePoolHorizontalSpan("minecraft:village/plains/town_centers"));
IllegalStateException structureSetKeys = assertThrows( IllegalStateException structureSetKeys = assertThrows(
IllegalStateException.class, hooks::structureSetKeys); IllegalStateException.class, hooks::structureSetKeys);
IllegalStateException structureBiomeKeys = assertThrows( IllegalStateException structureBiomeKeys = assertThrows(
@@ -88,6 +93,8 @@ public class ModdedStructureHooksTest {
assertTrue(structureKeys.getMessage().contains("before the Minecraft server is available")); assertTrue(structureKeys.getMessage().contains("before the Minecraft server is available"));
assertTrue(jigsawStructureKeys.getMessage().contains("before the Minecraft server is available")); assertTrue(jigsawStructureKeys.getMessage().contains("before the Minecraft server is available"));
assertTrue(templatePoolKeys.getMessage().contains("before the Minecraft server is available")); assertTrue(templatePoolKeys.getMessage().contains("before the Minecraft server is available"));
assertTrue(jigsawMetadata.getMessage().contains("before the Minecraft server is available"));
assertTrue(templatePoolSpan.getMessage().contains("before the Minecraft server is available"));
assertTrue(structureSetKeys.getMessage().contains("before the Minecraft server is available")); assertTrue(structureSetKeys.getMessage().contains("before the Minecraft server is available"));
assertTrue(structureBiomeKeys.getMessage().contains("before the Minecraft server is available")); assertTrue(structureBiomeKeys.getMessage().contains("before the Minecraft server is available"));
} }
@@ -56,7 +56,7 @@ public class NativeStructureFailureContractTest {
} }
@Test @Test
public void structureTerrainPreparationPrecedesVegetationAndPlacement() throws IOException { public void structureVegetationCleanupPrecedesTerrainPreparationAndPlacement() throws IOException {
String source = moddedSource("ModdedNativeStructureStage.java"); String source = moddedSource("ModdedNativeStructureStage.java");
int placementStart = source.indexOf("void placeVanillaStructures"); int placementStart = source.indexOf("void placeVanillaStructures");
int placementEnd = source.indexOf("private static String nativeStructureBatchContext", placementStart); int placementEnd = source.indexOf("private static String nativeStructureBatchContext", placementStart);
@@ -65,9 +65,9 @@ public class NativeStructureFailureContractTest {
assertTrue(placement.contains("\"terrain integration\"")); assertTrue(placement.contains("\"terrain integration\""));
assertTrue(placement.contains("prepareSurfaceStructures")); assertTrue(placement.contains("prepareSurfaceStructures"));
assertTrue(placement.contains("clearIntersectingVegetation")); assertTrue(placement.contains("clearIntersectingVegetation"));
assertTrue(placement.indexOf("prepareSurfaceStructures")
< placement.indexOf("clearIntersectingVegetation"));
assertTrue(placement.indexOf("clearIntersectingVegetation") assertTrue(placement.indexOf("clearIntersectingVegetation")
< placement.indexOf("prepareSurfaceStructures"));
assertTrue(placement.indexOf("prepareSurfaceStructures")
< placement.indexOf("for (NativePlacementGroup group")); < placement.indexOf("for (NativePlacementGroup group"));
} }
@@ -86,6 +86,7 @@ public class NativeStructureFailureContractTest {
< placement.indexOf("prepareSurfaceStructures")); < placement.indexOf("prepareSurfaceStructures"));
assertTrue(source.contains("generationEngine.getHeight(x, z, false) + runtimeMinY + 1")); assertTrue(source.contains("generationEngine.getHeight(x, z, false) + runtimeMinY + 1"));
assertTrue(source.contains("generationEngine.getHeight(x, z, true) + runtimeMinY + 1")); assertTrue(source.contains("generationEngine.getHeight(x, z, true) + runtimeMinY + 1"));
assertTrue(source.contains("int minY = chunk.getMinY() + 1;"));
} }
@Test @Test
@@ -0,0 +1,53 @@
package art.arcane.iris.modded;
import art.arcane.iris.core.project.SchemaBuilder;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
public class SchemaBuilderPlatformIsolationTest {
@Test
public void enumSchemaDoesNotResolveBukkitOnlyMethodSignatures() {
assertThrows(ClassNotFoundException.class, () -> Class.forName("org.bukkit.block.BlockFace"));
JSONObject schema = new SchemaBuilder(DirectionModel.class, null).construct();
JSONObject direction = schema.getJSONObject("properties").getJSONObject("direction");
String definitionKey = direction.getString("$ref").substring("#/definitions/".length());
JSONArray values = schema.getJSONObject("definitions").getJSONObject(definitionKey).getJSONArray("oneOf");
List<String> names = new ArrayList<>(values.length());
for (int index = 0; index < values.length(); index++) {
names.add(values.getJSONObject(index).getString("const"));
}
assertEquals(List.of(
"UP_POSITIVE_Y",
"DOWN_NEGATIVE_Y",
"NORTH_NEGATIVE_Z",
"SOUTH_POSITIVE_Z",
"EAST_POSITIVE_X",
"WEST_NEGATIVE_X"), names);
assertEquals("#/definitions/" + definitionKey,
schema.getJSONObject("properties").getJSONObject("directions")
.getJSONObject("items").getString("$ref"));
}
@Desc("Direction model.")
public static class DirectionModel {
@Desc("Direction.")
private IrisDirection direction = IrisDirection.NORTH_NEGATIVE_Z;
@Desc("Directions.")
@ArrayType(type = IrisDirection.class)
private KList<IrisDirection> directions = new KList<>();
}
}
@@ -1,15 +1,49 @@
package art.arcane.iris.modded.command; package art.arcane.iris.modded.command;
import art.arcane.iris.nativegen.NativeStructureLocateResults;
import com.mojang.datafixers.util.Pair;
import net.minecraft.core.BlockPos;
import org.junit.Test; import org.junit.Test;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class IrisModdedStructureCommandTest { public class IrisModdedStructureCommandTest {
@Test
public void mixedUnexploredLocateReferencesOnlyTheSelectedProvider() {
BlockPos origin = BlockPos.ZERO;
Pair<BlockPos, String> irisNear = Pair.of(new BlockPos(4, 70, 0), "iris");
Pair<BlockPos, String> nativeFar = Pair.of(new BlockPos(8, 70, 0), "native");
AtomicInteger irisReferences = new AtomicInteger();
AtomicInteger nativeReferences = new AtomicInteger();
Pair<BlockPos, String> irisSelected = NativeStructureLocateResults.selectAndReference(
origin,
irisNear, () -> irisReferences.incrementAndGet(),
nativeFar, () -> nativeReferences.incrementAndGet());
assertSame(irisNear, irisSelected);
assertEquals(1, irisReferences.get());
assertEquals(0, nativeReferences.get());
Pair<BlockPos, String> nativeNear = Pair.of(new BlockPos(2, 70, 0), "native");
Pair<BlockPos, String> nativeSelected = NativeStructureLocateResults.selectAndReference(
origin,
irisNear, () -> irisReferences.incrementAndGet(),
nativeNear, () -> nativeReferences.incrementAndGet());
assertSame(nativeNear, nativeSelected);
assertEquals(1, irisReferences.get());
assertEquals(1, nativeReferences.get());
}
@Test @Test
public void gotoStructureSupportsIrisAndNativeRegistryTargets() throws IOException { public void gotoStructureSupportsIrisAndNativeRegistryTargets() throws IOException {
String source = source("ModdedLocateCommands.java"); String source = source("ModdedLocateCommands.java");
@@ -40,21 +74,34 @@ public class IrisModdedStructureCommandTest {
int methodStart = source.indexOf("Pair<BlockPos, Holder<Structure>> findNearestIrisStructure("); int methodStart = source.indexOf("Pair<BlockPos, Holder<Structure>> findNearestIrisStructure(");
int methodEnd = source.indexOf("HolderSet<Structure> filterReachableNativeStructures(", methodStart); int methodEnd = source.indexOf("HolderSet<Structure> filterReachableNativeStructures(", methodStart);
String method = source.substring(methodStart, methodEnd); String method = source.substring(methodStart, methodEnd);
int unexploredGuard = method.indexOf("if (findUnexplored)");
int registryLookup = method.indexOf("level.registryAccess().lookupOrThrow(Registries.STRUCTURE)"); int registryLookup = method.indexOf("level.registryAccess().lookupOrThrow(Registries.STRUCTURE)");
int placedCheck = method.indexOf( int placedCheck = method.indexOf(
"if (!IrisStructureLocator.isPlaced(current, structureId))"); "if (!IrisStructureLocator.hasNativePlacement(current, structureId))");
int irisLocate = method.indexOf("IrisStructureLocator.locate(", placedCheck); int irisLocate = method.indexOf("NativeStructureLocatePersistence.search(", placedCheck);
int nearestSelection = method.indexOf(
"NativeStructureLocateResults.nearest(pos, predicted, nativeLocated)", irisLocate);
int selectedVerification = method.indexOf("bestSearch.search().verify(bestResult)", nearestSelection);
int selectedReference = method.indexOf(
"selectedSearch.search().reference(selectedStart)", selectedVerification);
assertTrue(unexploredGuard >= 0); assertTrue(registryLookup >= 0);
assertTrue(registryLookup > unexploredGuard);
assertTrue(placedCheck > registryLookup); assertTrue(placedCheck > registryLookup);
assertTrue(irisLocate > placedCheck); assertTrue(irisLocate > placedCheck);
assertTrue(nearestSelection > irisLocate);
assertTrue(selectedVerification > nearestSelection);
assertTrue(selectedReference > selectedVerification);
assertTrue(method.contains("NativeStructureLocatePersistence.probe("));
assertTrue(method.contains("findUnexplored"));
assertTrue(method.contains("LocateStatus.SEARCH_LIMIT_REACHED")); assertTrue(method.contains("LocateStatus.SEARCH_LIMIT_REACHED"));
assertTrue(method.contains("new BlockPos(result.originX(), result.baseY(), result.originZ())")); assertTrue(method.contains("verified.ownership().locatorY()"));
assertTrue(method.contains("selectedSearch.search().reference(selectedStart)"));
assertTrue(method.contains("NativeStructureLocateResults.selectAndReference("));
assertFalse(method.contains("NativeStructureLocateCapability")); assertFalse(method.contains("NativeStructureLocateCapability"));
assertTrue(source.contains("structureBiomeSource.isStructureReachable(holder)")); assertTrue(source.contains("structureBiomeSource.isStructureReachable(holder)"));
assertFalse(source.contains("isPaperUnavailable")); assertFalse(source.contains("isPaperUnavailable"));
String generator = moddedSource("IrisModdedChunkGenerator.java");
assertTrue(generator.contains("NativeStructureVanillaLocator.predict("));
assertFalse(generator.contains("super.findNearestMapStructure(level, reachable"));
} }
@Test @Test
@@ -68,7 +115,7 @@ public class IrisModdedStructureCommandTest {
int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(engine, target.key(), false)", genericIrisLookup); int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(engine, target.key(), false)", genericIrisLookup);
int replacementCheck = method.indexOf( int replacementCheck = method.indexOf(
"decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution); "decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution);
int replacementLocate = method.indexOf("locateIrisStructure(source, level, engine, player, target.key())", int replacementLocate = method.indexOf("runNativeStructureLocate(source, level, player, target)",
replacementCheck); replacementCheck);
assertTrue(nativeResolution >= 0); assertTrue(nativeResolution >= 0);
@@ -76,6 +123,8 @@ public class IrisModdedStructureCommandTest {
assertTrue(policyResolution > genericIrisLookup); assertTrue(policyResolution > genericIrisLookup);
assertTrue(replacementCheck > policyResolution); assertTrue(replacementCheck > policyResolution);
assertTrue(replacementLocate > replacementCheck); assertTrue(replacementLocate > replacementCheck);
assertTrue(method.contains("!IrisStructureLocator.hasNativePlacement(engine, target.key())"));
assertTrue(method.contains("&& target.availability() != NativeStructureAvailability.AVAILABLE"));
} }
@Test @Test
@@ -0,0 +1,442 @@
package art.arcane.iris.nativegen;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.IrisEngine;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.framework.NativeStructureOwnershipBundle;
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
import art.arcane.iris.engine.framework.NativeStructureOwnershipStore;
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisNativeStructure;
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
import art.arcane.iris.engine.object.IrisStructurePlacement;
import art.arcane.iris.engine.object.IrisStructureTerrain;
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
import art.arcane.volmlib.util.collection.KList;
import com.github.benmanes.caffeine.cache.Cache;
import net.minecraft.SharedConstants;
import net.minecraft.core.Direction;
import net.minecraft.core.HolderSet;
import net.minecraft.core.Registry;
import net.minecraft.core.RegistryAccess;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.resources.Identifier;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.StructureManager;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ProtoChunk;
import net.minecraft.world.level.chunk.UpgradeData;
import net.minecraft.world.level.levelgen.LegacyRandomSource;
import net.minecraft.world.level.levelgen.RandomSupport;
import net.minecraft.world.level.levelgen.WorldgenRandom;
import net.minecraft.world.level.levelgen.structure.Structure;
import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces;
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure;
import org.junit.BeforeClass;
import org.junit.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public class NativeStructureReferenceRepairTest {
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void persistedEnvelopeRepairsReloadedMonumentReferenceOutsideLiveBounds() {
long seed = 119723L;
ChunkPos origin = new ChunkPos(-3, 6);
OceanMonumentStructure structure = new OceanMonumentStructure(
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
StructureStart generated = monumentStart(structure, origin, seed);
NativeStructureVerticalPlacer.alignOceanMonumentToSeaLevel(
generated, 0, 80, -64, 320);
IrisStructureTerrain terrain = new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
.setHorizontalPadding(24);
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
generated,
structure,
0,
terrain);
NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture(
"minecraft:monument",
wrapped,
plan(origin, NativeStructureReferenceEnvelope.contentBounds(wrapped).minY()),
NativeStructureReferenceEnvelope.referenceBounds(wrapped, structure, terrain));
PiecesContainer regeneratedPieces = OceanMonumentStructure.regeneratePiecesAfterLoad(
origin, seed, new PiecesContainer(wrapped.getPieces()));
StructureStart reloaded = new StructureStart(structure, origin, 0, regeneratedPieces);
ChunkPos target = outsideLiveBounds(ownership, reloaded);
assertNotNull(target);
assertTrue(NativeStructureReferenceRepair.requiresReference(
target, "minecraft:monument", reloaded, ownership));
assertFalse(NativeStructureReferenceRepair.requiresReference(
target, "minecraft:monument", reloaded, null));
}
@Test
public void deniedNaturalStartsCannotEnterCollisionArbitration() {
IrisNativeStructureDecision enabled = new IrisNativeStructureDecision(
NativeStructureGenerationStatus.GENERATE_NATIVE,
0, null, false, false, null, new IrisStructureTerrain());
IrisNativeStructureDecision disabled = new IrisNativeStructureDecision(
NativeStructureGenerationStatus.DISABLED_BY_PACK,
0, null, false, false, null, new IrisStructureTerrain());
IrisNativeStructureDecision replaced = new IrisNativeStructureDecision(
NativeStructureGenerationStatus.REPLACED_BY_IRIS,
0, null, false, false, null, new IrisStructureTerrain());
assertTrue(NativeStructureReferenceRepair.naturalDecisionAllows(enabled));
assertFalse(NativeStructureReferenceRepair.naturalDecisionAllows(disabled));
assertFalse(NativeStructureReferenceRepair.naturalDecisionAllows(replaced));
}
@Test
public void persistedManualOwnershipSurvivesDisabledNaturalPolicyDuringReferenceScans()
throws Exception {
String structureKey = "minecraft:monument";
long seed = 582119L;
ChunkPos origin = new ChunkPos(2, -4);
OceanMonumentStructure structure = new OceanMonumentStructure(
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
StructureStart generated = monumentStart(structure, origin, seed);
NativeStructureVerticalPlacer.alignOceanMonumentToSeaLevel(
generated, 0, 80, -64, 320);
IrisStructureTerrain terrain = new IrisStructureTerrain()
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
.setHorizontalPadding(24);
StructureStart start = NativeStructureReferenceEnvelope.wrap(
generated, structure, 0, terrain);
NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture(
structureKey,
start,
plan(origin, NativeStructureReferenceEnvelope.contentBounds(start).minY()),
NativeStructureReferenceEnvelope.referenceBounds(start, structure, terrain));
IrisDimension dimension = new IrisDimension();
dimension.getImportedStructures().getDisabled().add(structureKey);
IrisData data = allocateWithoutConstructor(IrisData.class);
Engine engine = engine(dimension, data);
IrisNativeStructureDecision currentDecision = NativeStructureGenerationPolicy.resolve(
engine, structureKey, false);
assertFalse(currentDecision.generate());
assertFalse(NativeStructureReferenceRepair.naturalDecisionAllows(currentDecision));
ProtoChunk originChunk = emptyChunk(origin);
originChunk.setStartForStructure(structure, start);
ProtoChunk emptyScannedChunk = emptyChunk(new ChunkPos(Integer.MIN_VALUE, Integer.MIN_VALUE));
Registry<Structure> registry = structureRegistry(structure, structureKey);
ServerLevel serverLevel = allocateWithoutConstructor(ServerLevel.class);
StructureManager structureManager = new StructureManager(null, null, null);
ChunkPos coveredTarget = new ChunkPos(
ownership.referenceMinChunkX(), ownership.referenceMinChunkZ());
ChunkPos uncoveredTarget = outsideReferenceEnvelopeWithinScan(ownership);
assertNotNull(uncoveredTarget);
installOwnership(engine, ownership);
try {
WorldGenLevel level = worldGenLevel(
registry, serverLevel, originChunk, emptyScannedChunk);
ProtoChunk coveredChunk = emptyChunk(coveredTarget);
NativeStructureReferenceRepair.createReferences(
engine, level, structureManager, coveredChunk);
assertSame(start, originChunk.getStartForStructure(structure));
assertTrue(originChunk.getStartForStructure(structure).isValid());
assertTrue(coveredChunk.getReferencesForStructure(structure).contains(origin.pack()));
ProtoChunk uncoveredChunk = emptyChunk(uncoveredTarget);
NativeStructureReferenceRepair.createReferences(
engine, level, structureManager, uncoveredChunk);
assertSame(start, originChunk.getStartForStructure(structure));
assertTrue(originChunk.getStartForStructure(structure).isValid());
assertFalse(uncoveredChunk.getReferencesForStructure(structure).contains(origin.pack()));
} finally {
NativeStructureOwnershipStore.close(engine);
}
}
private static StructureStart monumentStart(OceanMonumentStructure structure,
ChunkPos origin, long seed) {
WorldgenRandom random = new WorldgenRandom(
new LegacyRandomSource(RandomSupport.generateUniqueSeed()));
random.setLargeFeatureSeed(seed, origin.x(), origin.z());
Direction orientation = Direction.Plane.HORIZONTAL.getRandomDirection(random);
OceanMonumentPieces.MonumentBuilding building = new OceanMonumentPieces.MonumentBuilding(
random,
origin.getMinBlockX() - 29,
origin.getMinBlockZ() - 29,
orientation
);
return new StructureStart(
structure,
origin,
0,
new PiecesContainer(List.of(building))
);
}
private static NativeStructureStartPlan plan(ChunkPos origin, int baseY) {
IrisNativeStructure source = new IrisNativeStructure()
.setStructure("minecraft:monument")
.setWeight(1);
IrisStructurePlacement placement = new IrisStructurePlacement()
.setPlacementId("monument-reference-test")
.setNativeStructures(new KList<IrisNativeStructure>().qadd(source));
return new NativeStructureStartPlan(
placement,
source,
origin.x(),
origin.z(),
baseY
);
}
private static ChunkPos outsideLiveBounds(NativeStructureOwnershipRecord ownership,
StructureStart start) {
for (int chunkX = ownership.referenceMinChunkX();
chunkX <= ownership.referenceMaxChunkX(); chunkX++) {
for (int chunkZ = ownership.referenceMinChunkZ();
chunkZ <= ownership.referenceMaxChunkZ(); chunkZ++) {
ChunkPos candidate = new ChunkPos(chunkX, chunkZ);
if (!start.getBoundingBox().intersects(
candidate.getMinBlockX(), candidate.getMinBlockZ(),
candidate.getMaxBlockX(), candidate.getMaxBlockZ())) {
return candidate;
}
}
}
return null;
}
private static ChunkPos outsideReferenceEnvelopeWithinScan(
NativeStructureOwnershipRecord ownership) {
int originX = ownership.originChunkX();
int originZ = ownership.originChunkZ();
List<ChunkPos> candidates = List.of(
new ChunkPos(ownership.referenceMinChunkX() - 1, originZ),
new ChunkPos(ownership.referenceMaxChunkX() + 1, originZ),
new ChunkPos(originX, ownership.referenceMinChunkZ() - 1),
new ChunkPos(originX, ownership.referenceMaxChunkZ() + 1));
for (ChunkPos candidate : candidates) {
if (!ownership.covers(candidate.x(), candidate.z())
&& Math.abs(candidate.x() - originX)
<= NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS
&& Math.abs(candidate.z() - originZ)
<= NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS) {
return candidate;
}
}
return null;
}
private static ProtoChunk emptyChunk(ChunkPos position) {
return new ProtoChunk(
position,
UpgradeData.EMPTY,
LevelHeightAccessor.create(0, 0),
null,
null);
}
@SuppressWarnings("unchecked")
private static Registry<Structure> structureRegistry(
Structure structure, String structureKey) {
Identifier identifier = Identifier.parse(structureKey);
InvocationHandler handler = (Object proxy, Method method, Object[] arguments) -> {
if (method.getName().equals("getKey")) {
return arguments != null && arguments.length == 1
&& arguments[0] == structure ? identifier : null;
}
return defaultProxyValue(proxy, method, arguments);
};
return (Registry<Structure>) Proxy.newProxyInstance(
Registry.class.getClassLoader(),
new Class<?>[]{Registry.class},
handler);
}
private static WorldGenLevel worldGenLevel(
Registry<Structure> registry,
ServerLevel serverLevel,
ChunkAccess originChunk,
ChunkAccess emptyChunk) {
RegistryAccess registryAccess = registryAccess(registry);
ChunkPos origin = originChunk.getPos();
InvocationHandler handler = (Object proxy, Method method, Object[] arguments) -> {
return switch (method.getName()) {
case "registryAccess" -> registryAccess;
case "getLevel" -> serverLevel;
case "getChunk" -> arguments != null
&& arguments.length >= 2
&& ((Integer) arguments[0]) == origin.x()
&& ((Integer) arguments[1]) == origin.z()
? originChunk : emptyChunk;
default -> defaultProxyValue(proxy, method, arguments);
};
};
return (WorldGenLevel) Proxy.newProxyInstance(
WorldGenLevel.class.getClassLoader(),
new Class<?>[]{WorldGenLevel.class},
handler);
}
private static RegistryAccess registryAccess(Registry<Structure> registry) {
InvocationHandler handler = (Object proxy, Method method, Object[] arguments) -> {
return switch (method.getName()) {
case "lookupOrThrow" -> registry;
case "lookup" -> Optional.of(registry);
case "registries" -> Stream.empty();
default -> defaultProxyValue(proxy, method, arguments);
};
};
return (RegistryAccess) Proxy.newProxyInstance(
RegistryAccess.class.getClassLoader(),
new Class<?>[]{RegistryAccess.class},
handler);
}
private static Engine engine(IrisDimension dimension, IrisData data) throws Exception {
TestEngine engine = allocateWithoutConstructor(TestEngine.class);
engine.dimension = dimension;
engine.data = data;
return engine;
}
@SuppressWarnings("unchecked")
private static void installOwnership(
Engine engine, NativeStructureOwnershipRecord ownership) throws Exception {
NativeStructureOwnershipBundle bundle =
NativeStructureOwnershipBundle.empty().with(ownership);
Class<?> storageType = Class.forName(
"art.arcane.iris.engine.framework.NativeStructureOwnershipStore$Storage");
InvocationHandler storageHandler =
(Object proxy, Method method, Object[] arguments) -> {
return switch (method.getName()) {
case "read" -> arguments != null
&& arguments.length == 2
&& ((Integer) arguments[0]) == ownership.originChunkX()
&& ((Integer) arguments[1]) == ownership.originChunkZ()
? bundle : null;
case "write", "remove" -> null;
default -> defaultProxyValue(proxy, method, arguments);
};
};
Object storage = Proxy.newProxyInstance(
storageType.getClassLoader(),
new Class<?>[]{storageType},
storageHandler);
Class<?> stateType = Class.forName(
"art.arcane.iris.engine.framework.NativeStructureOwnershipStore$State");
Constructor<?> constructor = stateType.getDeclaredConstructor(Engine.class, storageType);
constructor.setAccessible(true);
Object state = constructor.newInstance(engine, storage);
Field statesField = NativeStructureOwnershipStore.class.getDeclaredField("STATES");
statesField.setAccessible(true);
Cache<Engine, Object> states = (Cache<Engine, Object>) statesField.get(null);
states.put(engine, state);
}
private static <T> T allocateWithoutConstructor(Class<T> type) throws Exception {
Class<?> unsafeType = Class.forName("sun.misc.Unsafe");
Field unsafeField = unsafeType.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
Object unsafe = unsafeField.get(null);
Method allocateInstance = unsafeType.getMethod("allocateInstance", Class.class);
return type.cast(allocateInstance.invoke(unsafe, type));
}
private static Object defaultProxyValue(
Object proxy, Method method, Object[] arguments) {
return switch (method.getName()) {
case "equals" -> arguments != null && arguments.length == 1
&& proxy == arguments[0];
case "hashCode" -> System.identityHashCode(proxy);
case "toString" -> proxy.getClass().getInterfaces()[0].getSimpleName() + "Proxy";
default -> primitiveDefault(method.getReturnType());
};
}
private static Object primitiveDefault(Class<?> returnType) {
if (!returnType.isPrimitive() || returnType == void.class) {
return null;
}
if (returnType == boolean.class) {
return false;
}
if (returnType == char.class) {
return '\0';
}
if (returnType == byte.class) {
return (byte) 0;
}
if (returnType == short.class) {
return (short) 0;
}
if (returnType == int.class) {
return 0;
}
if (returnType == long.class) {
return 0L;
}
if (returnType == float.class) {
return 0.0F;
}
return 0.0D;
}
private static final class TestEngine extends IrisEngine {
private IrisDimension dimension;
private IrisData data;
private TestEngine() {
super(null, false);
}
@Override
public IrisDimension getDimension() {
return dimension;
}
@Override
public IrisData getData() {
return data;
}
@Override
public boolean isClosing() {
return false;
}
@Override
public boolean isClosed() {
return false;
}
}
}
+2
View File
@@ -120,6 +120,8 @@ nmsBindings.each { key, value ->
rootProject.file("adapters/bukkit/nms/${key}/src/main/java/art/arcane/iris/core/nms/${key}/IrisChunkGenerator.java").absolutePath) rootProject.file("adapters/bukkit/nms/${key}/src/main/java/art/arcane/iris/core/nms/${key}/IrisChunkGenerator.java").absolutePath)
systemProperty('iris.nativeStructurePostProcessorSource', systemProperty('iris.nativeStructurePostProcessorSource',
rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java').absolutePath) rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java').absolutePath)
systemProperty('iris.nativeStructureStartInjectorSource',
rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java').absolutePath)
systemProperty('iris.customBiomeSource', systemProperty('iris.customBiomeSource',
rootProject.file("adapters/bukkit/nms/${key}/src/main/java/art/arcane/iris/core/nms/${key}/CustomBiomeSource.java").absolutePath) rootProject.file("adapters/bukkit/nms/${key}/src/main/java/art/arcane/iris/core/nms/${key}/CustomBiomeSource.java").absolutePath)
systemProperty('iris.vanillaStructureBiomesSource', systemProperty('iris.vanillaStructureBiomesSource',
+4
View File
@@ -119,6 +119,10 @@ dependencies {
testRuntimeOnly(libs.paper.api) testRuntimeOnly(libs.paper.api)
} }
tasks.named('test').configure {
maxHeapSize = '1g'
}
java { java {
toolchain { toolchain {
languageVersion = JavaLanguageVersion.of(25) languageVersion = JavaLanguageVersion.of(25)
+4 -1
View File
@@ -2,14 +2,18 @@ art/arcane/iris/core/IrisRuntimeSchedulerMode.java
art/arcane/iris/core/IrisWorldStorage.java art/arcane/iris/core/IrisWorldStorage.java
art/arcane/iris/core/IrisWorlds.java art/arcane/iris/core/IrisWorlds.java
art/arcane/iris/core/ServerConfigurator.java art/arcane/iris/core/ServerConfigurator.java
art/arcane/iris/core/WorldCreatorCompat.java
art/arcane/iris/core/WorldRemovalPathPolicy.java
art/arcane/iris/core/datapack/DatapackIngestService.java art/arcane/iris/core/datapack/DatapackIngestService.java
art/arcane/iris/core/edit/BlockSignal.java art/arcane/iris/core/edit/BlockSignal.java
art/arcane/iris/core/edit/DustRevealer.java art/arcane/iris/core/edit/DustRevealer.java
art/arcane/iris/core/events/IrisEngineEvent.java art/arcane/iris/core/events/IrisEngineEvent.java
art/arcane/iris/core/events/IrisEngineHotloadEvent.java art/arcane/iris/core/events/IrisEngineHotloadEvent.java
art/arcane/iris/core/events/IrisLootEvent.java art/arcane/iris/core/events/IrisLootEvent.java
art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java
art/arcane/iris/core/lifecycle/BukkitPublicBackend.java art/arcane/iris/core/lifecycle/BukkitPublicBackend.java
art/arcane/iris/core/lifecycle/CapabilitySnapshot.java art/arcane/iris/core/lifecycle/CapabilitySnapshot.java
art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java
art/arcane/iris/core/lifecycle/PaperLibBootstrap.java art/arcane/iris/core/lifecycle/PaperLibBootstrap.java
art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java
art/arcane/iris/core/lifecycle/WorldLifecycleBackend.java art/arcane/iris/core/lifecycle/WorldLifecycleBackend.java
@@ -115,7 +119,6 @@ art/arcane/iris/engine/object/IrisVanillaLootTable.java
art/arcane/iris/engine/object/LegacyTileData.java art/arcane/iris/engine/object/LegacyTileData.java
art/arcane/iris/engine/object/PotionEffectTypes.java art/arcane/iris/engine/object/PotionEffectTypes.java
art/arcane/iris/engine/object/TileData.java art/arcane/iris/engine/object/TileData.java
art/arcane/iris/engine/object/annotations/functions/LootTableKeyFunction.java
art/arcane/iris/engine/platform/BukkitChunkGenerator.java art/arcane/iris/engine/platform/BukkitChunkGenerator.java
art/arcane/iris/engine/platform/DummyBiomeProvider.java art/arcane/iris/engine/platform/DummyBiomeProvider.java
art/arcane/iris/engine/platform/DummyChunkGenerator.java art/arcane/iris/engine/platform/DummyChunkGenerator.java
@@ -0,0 +1,44 @@
package art.arcane.iris.core;
import java.util.Objects;
public record DatapackInstallResult(Status status) {
public DatapackInstallResult {
Objects.requireNonNull(status, "status");
}
public static DatapackInstallResult failedResult() {
return new DatapackInstallResult(Status.FAILED);
}
public static DatapackInstallResult unchangedResult() {
return new DatapackInstallResult(Status.UNCHANGED);
}
public static DatapackInstallResult readyResult() {
return new DatapackInstallResult(Status.READY);
}
public static DatapackInstallResult restartRequiredResult() {
return new DatapackInstallResult(Status.RESTART_REQUIRED);
}
public boolean succeeded() {
return status != Status.FAILED;
}
public boolean changed() {
return status == Status.READY || status == Status.RESTART_REQUIRED;
}
public boolean restartRequired() {
return status == Status.RESTART_REQUIRED;
}
public enum Status {
FAILED,
UNCHANGED,
READY,
RESTART_REQUIRED
}
}
@@ -3,6 +3,8 @@ package art.arcane.iris.core;
import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.nms.datapack.IDataFixer; import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
@@ -10,8 +12,12 @@ import art.arcane.volmlib.util.collection.KSet;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
@@ -19,6 +25,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicIntegerArray; import java.util.concurrent.atomic.AtomicIntegerArray;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -49,9 +56,7 @@ public final class IrisDatapackCompiler {
throw new IOException("No Iris datapack output roots were provided"); throw new IOException("No Iris datapack output roots were provided");
} }
for (File datapackRoot : datapackRoots) { resetOutputRoots(datapackRoots);
Files.createDirectories(datapackRoot.toPath());
}
IrisDimension.clearGeneratedBiomeTags(datapackRoots); IrisDimension.clearGeneratedBiomeTags(datapackRoots);
DimensionHeight height = new DimensionHeight(fixer); DimensionHeight height = new DimensionHeight(fixer);
@@ -59,6 +64,7 @@ public final class IrisDatapackCompiler {
int packCount = 0; int packCount = 0;
int dimensionCount = 0; int dimensionCount = 0;
for (File packRoot : packRoots) { for (File packRoot : packRoots) {
PackDirectoryResolver.requireSafePackTree(packRoot);
if (!hasDimensions(packRoot.toPath())) { if (!hasDimensions(packRoot.toPath())) {
continue; continue;
} }
@@ -93,42 +99,42 @@ public final class IrisDatapackCompiler {
} }
IrisDimension.writeShared(datapackRoots, height, packFormat, adjustVanillaHeight); IrisDimension.writeShared(datapackRoots, height, packFormat, adjustVanillaHeight);
validateOutputs(datapackRoots); validateOutputs(datapackRoots, dimensionCount);
return new CompilationResult(packCount, dimensionCount, countBiomes(biomes)); return new CompilationResult(packCount, dimensionCount, countBiomes(biomes));
} }
private static void collectInstalledPackRoots(Path packsRoot, Map<Path, File> roots) throws IOException { private static void collectInstalledPackRoots(Path packsRoot, Map<Path, File> roots) throws IOException {
if (!Files.isDirectory(packsRoot)) { List<File> candidates = PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(packsRoot.toFile());
return; for (File candidate : candidates) {
} addPackRoot(candidate.toPath(), roots);
try (Stream<Path> stream = Files.list(packsRoot)) {
List<Path> candidates = stream
.filter(Files::isDirectory)
.sorted(Comparator.comparing(Path::toString))
.toList();
for (Path candidate : candidates) {
addPackRoot(candidate, roots);
}
} }
} }
private static void collectWorldPackRoots(Path dimensionsRoot, Map<Path, File> roots) throws IOException { private static void collectWorldPackRoots(Path dimensionsRoot, Map<Path, File> roots) throws IOException {
if (!Files.isDirectory(dimensionsRoot)) { if (Files.isSymbolicLink(dimensionsRoot)
|| !Files.isDirectory(dimensionsRoot, LinkOption.NOFOLLOW_LINKS)) {
return; return;
} }
try (Stream<Path> stream = Files.find( List<Path> candidates = new ArrayList<>();
dimensionsRoot, Files.walkFileTree(dimensionsRoot, Set.of(), WORLD_PACK_SCAN_DEPTH, new SimpleFileVisitor<>() {
WORLD_PACK_SCAN_DEPTH, @Override
(path, attributes) -> attributes.isDirectory() public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
&& "pack".equals(path.getFileName().toString()) if (!directory.equals(dimensionsRoot)
&& path.getParent() != null && PackDirectoryResolver.containsHiddenPathSegment(dimensionsRoot, directory)) {
&& "iris".equals(path.getParent().getFileName().toString()) return FileVisitResult.SKIP_SUBTREE;
&& hasDimensions(path) }
)) { if ("pack".equals(directory.getFileName().toString())
List<Path> candidates = stream.sorted(Comparator.comparing(Path::toString)).toList(); && directory.getParent() != null
for (Path candidate : candidates) { && "iris".equals(directory.getParent().getFileName().toString())
addPackRoot(candidate, roots); && hasDimensions(directory)) {
candidates.add(directory);
}
return FileVisitResult.CONTINUE;
} }
});
candidates.sort(Comparator.comparing(Path::toString));
for (Path candidate : candidates) {
addPackRoot(candidate, roots);
} }
} }
@@ -137,29 +143,52 @@ public final class IrisDatapackCompiler {
return; return;
} }
Path normalized = root.toAbsolutePath().normalize(); Path normalized = root.toAbsolutePath().normalize();
if (!Files.isDirectory(normalized)) {
return;
}
PackDirectoryResolver.requireSafePackTree(normalized.toFile());
Path identity = normalized.toRealPath(); Path identity = normalized.toRealPath();
roots.putIfAbsent(identity, normalized.toFile()); roots.putIfAbsent(identity, normalized.toFile());
} }
private static boolean hasDimensions(Path root) { private static boolean hasDimensions(Path root) {
Path dimensions = root.resolve("dimensions"); Path dimensions = root.resolve("dimensions");
if (!Files.isDirectory(dimensions)) { if (Files.isSymbolicLink(dimensions)
|| !Files.isDirectory(dimensions, LinkOption.NOFOLLOW_LINKS)) {
return false; return false;
} }
try (Stream<Path> stream = Files.list(dimensions)) { try (Stream<Path> stream = Files.list(dimensions)) {
return stream.anyMatch(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".json")); return stream.anyMatch(path -> !Files.isSymbolicLink(path)
&& Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
&& path.getFileName().toString().endsWith(".json"));
} catch (IOException e) { } catch (IOException e) {
return false; return false;
} }
} }
private static void validateOutputs(Collection<File> datapackRoots) throws IOException { private static void resetOutputRoots(Collection<File> datapackRoots) throws IOException {
for (File datapackRoot : datapackRoots) {
Path root = datapackRoot.toPath().toAbsolutePath().normalize();
if (Files.isSymbolicLink(root)) {
throw new IOException("Iris datapack output root is a symbolic link: " + root);
}
if (Files.exists(root, LinkOption.NOFOLLOW_LINKS)) {
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Iris datapack output root is not a directory: " + root);
}
AtomicDirectoryPublisher.deleteTree(root);
}
Files.createDirectories(root);
}
}
private static void validateOutputs(Collection<File> datapackRoots, int dimensionCount) throws IOException {
for (File datapackRoot : datapackRoots) { for (File datapackRoot : datapackRoots) {
Path root = datapackRoot.toPath(); Path root = datapackRoot.toPath();
if (!Files.isRegularFile(root.resolve("pack.mcmeta"))) { if (!Files.isRegularFile(root.resolve("pack.mcmeta"))) {
throw new IOException("Iris datapack metadata was not generated at " + root); throw new IOException("Iris datapack metadata was not generated at " + root);
} }
if (!Files.isDirectory(root.resolve("data/iris/dimension_type"))) { if (dimensionCount > 0 && !Files.isDirectory(root.resolve("data/iris/dimension_type"))) {
throw new IOException("Iris dimension types were not generated at " + root); throw new IOException("Iris dimension types were not generated at " + root);
} }
} }
@@ -11,6 +11,7 @@ import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Files;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
@@ -50,6 +51,10 @@ public final class IrisWorldStorage {
return cached; return cached;
} }
public static String configuredLevelName() {
return levelNameFromProperties(new File("server.properties"));
}
static String levelNameFromProperties(File serverProperties) { static String levelNameFromProperties(File serverProperties) {
Properties properties = new Properties(); Properties properties = new Properties();
if (Objects.requireNonNull(serverProperties, "serverProperties").isFile()) { if (Objects.requireNonNull(serverProperties, "serverProperties").isFile()) {
@@ -87,6 +92,39 @@ public final class IrisWorldStorage {
return keyFromName(worldName, levelRoot().getName()); return keyFromName(worldName, levelRoot().getName());
} }
public static NamespacedKey managedKeyFromName(String worldName) {
String requestedName = Objects.requireNonNull(worldName, "worldName").trim();
if (requestedName.contains(":")) {
return managedKeyFromName(requestedName, DEFAULT_LEVEL_NAME);
}
return managedKeyFromName(requestedName, levelRoot().getName());
}
public static NamespacedKey managedKeyFromName(String worldName, String levelName) {
String requestedName = Objects.requireNonNull(worldName, "worldName").trim();
if (requestedName.isEmpty()) {
throw new IllegalArgumentException("World name cannot be empty.");
}
if (requestedName.contains("/") || requestedName.contains("\\") || requestedName.contains("..")) {
throw new IllegalArgumentException("World name must be a safe single path segment.");
}
NamespacedKey key;
if (requestedName.contains(":")) {
key = NamespacedKey.fromString(requestedName.toLowerCase(Locale.ENGLISH));
if (key == null) {
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
}
} else {
key = keyFromName(requestedName, levelName);
}
if (!IRIS_NAMESPACE.equals(key.getNamespace()) || !key.getKey().matches("[a-z0-9_-]+")) {
throw new IllegalArgumentException("Only Iris-managed dimension worlds can be changed.");
}
return key;
}
static NamespacedKey keyFromName(String worldName, String levelName) { static NamespacedKey keyFromName(String worldName, String levelName) {
String name = Objects.requireNonNull(worldName, "worldName").trim(); String name = Objects.requireNonNull(worldName, "worldName").trim();
String mainLevelName = Objects.requireNonNull(levelName, "levelName").trim(); String mainLevelName = Objects.requireNonNull(levelName, "levelName").trim();
@@ -112,7 +150,11 @@ public final class IrisWorldStorage {
} }
public static String logicalName(NamespacedKey key) { public static String logicalName(NamespacedKey key) {
return logicalName(key, levelRoot().getName()); NamespacedKey worldKey = Objects.requireNonNull(key, "key");
if (IRIS_NAMESPACE.equals(worldKey.getNamespace())) {
return worldKey.getKey();
}
return logicalName(worldKey, levelRoot().getName());
} }
static String logicalName(NamespacedKey key, String levelName) { static String logicalName(NamespacedKey key, String levelName) {
@@ -147,6 +189,31 @@ public final class IrisWorldStorage {
return dimensionRoot(levelRoot(), key); return dimensionRoot(levelRoot(), key);
} }
public static File requireSafeManagedDimensionRoot(NamespacedKey key) {
return requireSafeManagedDimensionRoot(levelRoot(), key);
}
public static File requireSafeManagedDimensionRoot(File levelRoot, NamespacedKey key) {
NamespacedKey worldKey = Objects.requireNonNull(key, "key");
if (!IRIS_NAMESPACE.equals(worldKey.getNamespace()) || !worldKey.getKey().matches("[a-z0-9_-]+")) {
throw new IllegalArgumentException("Only safe Iris-managed dimension worlds can be changed.");
}
Path root = Objects.requireNonNull(levelRoot, "levelRoot").toPath().toAbsolutePath().normalize();
Path dimensions = root.resolve("dimensions");
Path namespace = dimensions.resolve(IRIS_NAMESPACE);
Path target = namespace.resolve(worldKey.getKey()).normalize();
if (!Objects.equals(target.getParent(), namespace)) {
throw new IllegalArgumentException("World target escapes the Iris namespace root.");
}
for (Path path : new Path[]{dimensions, namespace, target}) {
if (Files.isSymbolicLink(path)) {
throw new IllegalArgumentException("World storage path contains a symbolic link: " + path);
}
}
return target.toFile();
}
public static File dimensionRoot(File levelRoot, NamespacedKey key) { public static File dimensionRoot(File levelRoot, NamespacedKey key) {
Path dimensionsRoot = Objects.requireNonNull(levelRoot, "levelRoot") Path dimensionsRoot = Objects.requireNonNull(levelRoot, "levelRoot")
.toPath() .toPath()
@@ -22,8 +22,15 @@ import org.bukkit.configuration.file.YamlConfiguration;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStreamWriter; import java.io.UncheckedIOException;
import java.lang.reflect.Type; import java.lang.reflect.Type;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -62,9 +69,42 @@ public class IrisWorlds {
}); });
} }
public void put(String identity, String type) { public synchronized void put(String identity, String type) {
put0(identity, type); String canonicalIdentity = WorldIdentity.parse(identity).toString();
save(); String requiredType = Objects.requireNonNull(type, "type");
String previous = worlds.put(canonicalIdentity, requiredType);
if (requiredType.equals(previous)) {
return;
}
dirty = true;
try {
saveOrThrow();
} catch (IOException e) {
if (previous == null) {
worlds.remove(canonicalIdentity);
} else {
worlds.put(canonicalIdentity, previous);
}
dirty = true;
throw new UncheckedIOException("Failed to persist Iris world registry entry for " + canonicalIdentity, e);
}
}
public synchronized boolean remove(String identity) {
String canonicalIdentity = WorldIdentity.parse(identity).toString();
String previous = worlds.remove(canonicalIdentity);
if (previous == null) {
return false;
}
dirty = true;
try {
saveOrThrow();
return true;
} catch (IOException e) {
worlds.put(canonicalIdentity, previous);
dirty = true;
throw new UncheckedIOException("Failed to remove Iris world registry entry for " + canonicalIdentity, e);
}
} }
private void put0(String identity, String type) { private void put0(String identity, String type) {
@@ -74,7 +114,7 @@ public class IrisWorlds {
dirty = true; dirty = true;
} }
public KMap<String, String> getWorlds() { public synchronized KMap<String, String> getWorlds() {
clean(); clean();
KMap<String, String> result = new KMap<>(); KMap<String, String> result = new KMap<>();
readBukkitWorlds().forEach((name, type) -> result.put(IrisWorldStorage.keyFromName(name).toString(), type)); readBukkitWorlds().forEach((name, type) -> result.put(IrisWorldStorage.keyFromName(name).toString(), type));
@@ -95,7 +135,7 @@ public class IrisWorlds {
.filter(Objects::nonNull); .filter(Objects::nonNull);
} }
public void clean() { public synchronized void clean() {
boolean removed = worlds.entrySet().removeIf(entry -> { boolean removed = worlds.entrySet().removeIf(entry -> {
try { try {
File packRoot = IrisWorldStorage.packRoot(WorldIdentity.parse(entry.getKey())); File packRoot = IrisWorldStorage.packRoot(WorldIdentity.parse(entry.getKey()));
@@ -108,18 +148,43 @@ public class IrisWorlds {
} }
public synchronized void save() { public synchronized void save() {
clean();
if (!dirty) return;
try { try {
IO.write(IrisPlatforms.get().dataFile("worlds.json"), OutputStreamWriter::new, writer -> GSON.toJson(worlds, TYPE, writer)); saveOrThrow();
dirty = false;
} catch (IOException e) { } catch (IOException e) {
IrisLogging.error("Failed to save worlds.json!"); IrisLogging.error("Failed to save worlds.json!");
e.printStackTrace();
IrisLogging.reportError(e); IrisLogging.reportError(e);
} }
} }
private void saveOrThrow() throws IOException {
clean();
if (!dirty) {
return;
}
Path target = IrisPlatforms.get().dataFile("worlds.json").toPath().toAbsolutePath().normalize();
Path parent = target.getParent();
if (parent == null) {
throw new IOException("worlds.json target has no parent: " + target);
}
Files.createDirectories(parent);
Path staged = Files.createTempFile(parent, ".iris-worlds-", ".json");
try {
Files.writeString(staged, GSON.toJson(worlds, TYPE), StandardCharsets.UTF_8);
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
channel.force(true);
}
try {
Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(staged, target, StandardCopyOption.REPLACE_EXISTING);
}
dirty = false;
} finally {
Files.deleteIfExists(staged);
}
}
public static Long readBukkitWorldSeed(String world) { public static Long readBukkitWorldSeed(String world) {
YamlConfiguration bukkit = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML); YamlConfiguration bukkit = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
ConfigurationSection worlds = bukkit.getConfigurationSection("worlds"); ConfigurationSection worlds = bukkit.getConfigurationSection("worlds");
@@ -22,10 +22,14 @@ import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.datapack.DatapackIngestService; import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.core.nms.datapack.DataVersion;
import art.arcane.iris.core.nms.datapack.IDataFixer; import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner; import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisBiomeCustom; import art.arcane.iris.engine.object.IrisBiomeCustom;
@@ -47,16 +51,27 @@ import org.jetbrains.annotations.Nullable;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Comparator;
import java.util.Collections; import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -64,6 +79,8 @@ import art.arcane.iris.core.localization.BukkitRuntimeMessages;
import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
public class ServerConfigurator { public class ServerConfigurator {
private static final Object DATAPACK_INSTALL_LOCK = new Object();
public static void configure() { public static void configure() {
IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration(); IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration();
if (s.isConfigureSpigotTimeoutTime()) { if (s.isConfigureSpigotTimeoutTime()) {
@@ -77,7 +94,10 @@ public class ServerConfigurator {
if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) { if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) {
IrisLogging.info("Paper loaded the Iris datapack during bootstrap; skipping the legacy startup install."); IrisLogging.info("Paper loaded the Iris datapack during bootstrap; skipping the legacy startup install.");
} else { } else {
installDataPacks(true); DatapackInstallResult result = installDataPacks(true);
if (result.restartRequired() && IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall()) {
restart();
}
} }
} }
@@ -124,20 +144,20 @@ public class ServerConfigurator {
return roots; return roots;
} }
public static boolean installDataPacks(boolean fullInstall) { public static DatapackInstallResult installDataPacks(boolean fullInstall) {
IDataFixer fixer = DataVersion.getDefault(); return installDataPacks(resolveDataFixer(), fullInstall);
if (fixer == null) {
DataVersion fallback = DataVersion.getLatest();
IrisLogging.warn("Primary datapack fixer was null, forcing latest fixer: " + fallback.getVersion());
fixer = fallback.get();
}
return installDataPacks(fixer, fullInstall);
} }
public static boolean installDataPacks(IDataFixer fixer, boolean fullInstall) { public static DatapackInstallResult installDataPacks(IDataFixer fixer, boolean fullInstall) {
synchronized (DATAPACK_INSTALL_LOCK) {
return installDataPacksLocked(fixer, fullInstall);
}
}
private static DatapackInstallResult installDataPacksLocked(IDataFixer fixer, boolean fullInstall) {
if (fixer == null) { if (fixer == null) {
IrisLogging.error("Unable to install datapacks, fixer is null!"); IrisLogging.error("Unable to install datapacks, fixer is null!");
return false; return DatapackInstallResult.failedResult();
} }
if (fullInstall) { if (fullInstall) {
IrisLogging.info("Checking Data Packs..."); IrisLogging.info("Checking Data Packs...");
@@ -145,7 +165,10 @@ public class ServerConfigurator {
IrisLogging.debug("Checking Data Packs..."); IrisLogging.debug("Checking Data Packs...");
} }
KList<File> datapacksFolders = getDatapacksFolder(); KList<File> datapacksFolders = getDatapacksFolder();
DatapackIngestService.reapplyFromStaging(datapacksFolders); if (!DatapackIngestService.reapplyFromStaging(datapacksFolders)) {
IrisLogging.error("Unable to compile Iris datapacks while external datapack recovery is incomplete.");
return DatapackInstallResult.failedResult();
}
List<File> packRoots; List<File> packRoots;
try (Stream<IrisData> stream = allPacks()) { try (Stream<IrisData> stream = allPacks()) {
packRoots = stream packRoots = stream
@@ -155,17 +178,58 @@ public class ServerConfigurator {
.toList(); .toList();
} }
KList<File> liveRoots = getIrisDatapackRoots();
KList<File> stagedRoots = new KList<>();
List<Path> stagedPaths = new ArrayList<>(liveRoots.size());
List<AtomicDirectoryPublisher.Publication> publications = new ArrayList<>(liveRoots.size());
try { try {
for (File liveRoot : liveRoots) {
Path target = liveRoot.toPath().toAbsolutePath().normalize();
Path parent = target.getParent();
if (parent == null) {
throw new IOException("Iris datapack root has no parent: " + target);
}
Files.createDirectories(parent);
Path staged = parent.resolve(".iris-compile-" + UUID.randomUUID());
Files.createDirectories(staged);
stagedPaths.add(staged);
stagedRoots.add(staged.toFile());
}
IrisDatapackCompiler.compile( IrisDatapackCompiler.compile(
packRoots, packRoots,
getIrisDatapackRoots(), stagedRoots,
fixer, fixer,
BukkitPlatform.dataPackFormat(), BukkitPlatform.dataPackFormat(),
IrisSettings.get().getGeneral().adjustVanillaHeight IrisSettings.get().getGeneral().adjustVanillaHeight
); );
} catch (IOException e) { for (int i = 0; i < liveRoots.size(); i++) {
publications.add(AtomicDirectoryPublisher.publish(
stagedRoots.get(i).toPath(),
liveRoots.get(i).toPath()
));
}
for (AtomicDirectoryPublisher.Publication publication : publications) {
publication.commit();
try {
publication.cleanupBackup();
} catch (IOException cleanupFailure) {
IrisLogging.warn("Iris datapack was committed but its backup could not be removed: "
+ cleanupFailure.getMessage());
}
}
} catch (IOException | RuntimeException e) {
closePublications(publications, e);
IrisLogging.reportError("Unable to compile Iris datapacks", e); IrisLogging.reportError("Unable to compile Iris datapacks", e);
return false; return DatapackInstallResult.failedResult();
} finally {
for (Path stagedPath : stagedPaths) {
try {
AtomicDirectoryPublisher.deleteTree(stagedPath);
} catch (IOException cleanupFailure) {
IrisLogging.warn("Failed to clean Iris datapack compilation stage " + stagedPath + ": "
+ cleanupFailure.getMessage());
}
}
} }
if (fullInstall) { if (fullInstall) {
IrisLogging.info("Data Packs Setup!"); IrisLogging.info("Data Packs Setup!");
@@ -173,74 +237,205 @@ public class ServerConfigurator {
IrisLogging.debug("Data Packs Setup!"); IrisLogging.debug("Data Packs Setup!");
} }
return fullInstall && verifyDataPacksPost(IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall()); boolean restartRequired = fullInstall && verifyDataPacksPost();
return restartRequired
? DatapackInstallResult.restartRequiredResult()
: DatapackInstallResult.readyResult();
} }
public static boolean installDataPacksIfChanged(boolean fullInstall) { private static IDataFixer resolveDataFixer() {
File packsDir = IrisPlatforms.get().dataFolder("packs"); IDataFixer fixer = DataVersion.getDefault();
String current = computePackFingerprint(packsDir); if (fixer != null) {
File cacheFile = new File(IrisPlatforms.get().dataFolder("cache"), "datapack-fingerprint"); return fixer;
String cached = ""; }
if (cacheFile.exists()) { DataVersion fallback = DataVersion.getLatest();
IrisLogging.warn("Primary datapack fixer was null, forcing latest fixer: " + fallback.getVersion());
return fallback.get();
}
private static void closePublications(List<AtomicDirectoryPublisher.Publication> publications, Throwable failure) {
for (int i = publications.size() - 1; i >= 0; i--) {
try { try {
cached = Files.readString(cacheFile.toPath(), StandardCharsets.UTF_8).trim(); publications.get(i).close();
} catch (IOException e) { } catch (IOException rollbackFailure) {
cached = ""; failure.addSuppressed(rollbackFailure);
} }
} }
if (!current.isEmpty() && current.equals(cached)) { }
IrisLogging.debug("Data packs unchanged, skipping install.");
return false; public static DatapackInstallResult installDataPacksIfChanged(boolean fullInstall) {
synchronized (DATAPACK_INSTALL_LOCK) {
File packsDir = IrisPlatforms.get().dataFolder("packs");
String current;
try {
current = computePackFingerprint(packsDir);
} catch (RuntimeException exception) {
IrisLogging.reportError("Unable to fingerprint Iris packs safely", exception);
return DatapackInstallResult.failedResult();
}
File cacheFile = new File(IrisPlatforms.get().dataFolder("cache"), "datapack-fingerprint");
String cached = "";
if (cacheFile.exists()) {
try {
cached = Files.readString(cacheFile.toPath(), StandardCharsets.UTF_8).trim();
} catch (IOException e) {
cached = "";
}
}
if (!current.isEmpty() && current.equals(cached)) {
IrisLogging.debug("Data packs unchanged, skipping install.");
return DatapackInstallResult.unchangedResult();
}
DatapackInstallResult result = installDataPacksLocked(resolveDataFixer(), fullInstall);
if (result.succeeded()) {
try {
writeFingerprintAtomic(cacheFile.toPath(), current);
} catch (IOException e) {
IrisLogging.warn("Failed to write datapack fingerprint cache: " + e.getMessage());
}
}
return result;
} }
boolean result = installDataPacks(fullInstall);
try {
cacheFile.getParentFile().mkdirs();
Files.writeString(cacheFile.toPath(), current, StandardCharsets.UTF_8);
} catch (IOException e) {
IrisLogging.warn("Failed to write datapack fingerprint cache: " + e.getMessage());
}
return result;
} }
public static String computePackFingerprint(File packsDir) { public static String computePackFingerprint(File packsDir) {
if (packsDir == null || !packsDir.isDirectory()) { if (packsDir == null) {
return "";
}
Path root = packsDir.toPath().toAbsolutePath().normalize();
if (Files.isSymbolicLink(root)) {
throw new IllegalArgumentException("Iris packs root is a symbolic link: " + root);
}
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
return ""; return "";
} }
try { try {
MessageDigest digest = MessageDigest.getInstance("SHA-256"); MessageDigest digest = MessageDigest.getInstance("SHA-256");
List<String> entries = new ArrayList<>(); List<FingerprintEntry> entries = collectFingerprintEntries(root);
collectFingerprintEntries(packsDir, packsDir.getAbsolutePath(), entries); entries.sort(Comparator.comparing(FingerprintEntry::relativePath));
Collections.sort(entries); byte[] buffer = new byte[8192];
for (String entry : entries) { for (FingerprintEntry entry : entries) {
digest.update(entry.getBytes(StandardCharsets.UTF_8)); byte[] relativePath = entry.relativePath().getBytes(StandardCharsets.UTF_8);
updateDigestInt(digest, relativePath.length);
digest.update(relativePath);
updateDigestLong(digest, Files.size(entry.source()));
try (InputStream input = Files.newInputStream(entry.source())) {
int read;
while ((read = input.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read);
}
}
}
} }
byte[] hash = digest.digest(); return HexFormat.of().formatHex(digest.digest());
StringBuilder sb = new StringBuilder(hash.length * 2); } catch (IOException exception) {
for (byte b : hash) { throw new UncheckedIOException("Unable to fingerprint Iris packs at " + root, exception);
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) { } catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e); throw new IllegalStateException("SHA-256 not available", e);
} }
} }
private static void collectFingerprintEntries(File dir, String rootPath, List<String> entries) { private static void writeFingerprintAtomic(Path target, String fingerprint) throws IOException {
File[] files = dir.listFiles(); Path absoluteTarget = target.toAbsolutePath().normalize();
if (files == null) { Path parent = absoluteTarget.getParent();
return; if (parent == null) {
throw new IOException("Datapack fingerprint target has no parent: " + absoluteTarget);
} }
for (File file : files) { Files.createDirectories(parent);
if (file.isDirectory()) { Path staged = Files.createTempFile(parent, ".datapack-fingerprint-", ".tmp");
if (file.getName().startsWith(".")) { try {
Files.writeString(staged, fingerprint, StandardCharsets.UTF_8);
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
channel.force(true);
}
try {
Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(staged);
}
}
private static List<FingerprintEntry> collectFingerprintEntries(Path root) throws IOException {
List<FingerprintEntry> entries = new ArrayList<>();
try (Stream<Path> children = Files.list(root)) {
for (Path child : children.toList()) {
String childName = child.getFileName().toString();
if (PackDirectoryResolver.isHiddenName(childName)) {
continue; continue;
} }
collectFingerprintEntries(file, rootPath, entries); if (Files.isSymbolicLink(child)) {
} else { if (!Files.isDirectory(child)) {
String relative = file.getAbsolutePath().substring(rootPath.length()); throw new IOException("Iris pack fingerprint rejected symbolic link: " + child);
entries.add(relative + "|" + file.length() + "|" + file.lastModified()); }
PackDirectoryResolver.requireSafePackTree(child.toFile());
collectFingerprintTree(child.toRealPath(), childName, entries);
} else if (Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
collectFingerprintTree(child, childName, entries);
} else if (Files.isRegularFile(child, LinkOption.NOFOLLOW_LINKS)) {
entries.add(new FingerprintEntry(child, childName));
} else {
throw new IOException("Iris pack fingerprint rejected unsupported entry: " + child);
}
} }
} }
return entries;
}
private static void collectFingerprintTree(
Path treeRoot,
String logicalRoot,
List<FingerprintEntry> entries
) throws IOException {
Files.walkFileTree(treeRoot, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
if (!directory.equals(treeRoot)
&& PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) {
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
if (PackDirectoryResolver.isHiddenName(file.getFileName().toString())) {
return FileVisitResult.CONTINUE;
}
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
throw new IOException("Iris pack fingerprint rejected symbolic link: " + file);
}
if (!attributes.isRegularFile()) {
throw new IOException("Iris pack fingerprint rejected unsupported entry: " + file);
}
String relative = treeRoot.relativize(file).toString().replace(File.separatorChar, '/');
entries.add(new FingerprintEntry(file, logicalRoot + "/" + relative));
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
throw new IOException("Unable to inspect Iris pack entry: " + file, failure);
}
});
}
private record FingerprintEntry(Path source, String relativePath) {
}
private static void updateDigestInt(MessageDigest digest, int value) {
for (int shift = Integer.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) {
digest.update((byte) (value >>> shift));
}
}
private static void updateDigestLong(MessageDigest digest, long value) {
for (int shift = Long.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) {
digest.update((byte) (value >>> shift));
}
} }
public static File resolveDatapacksFolder(File worldFolder) { public static File resolveDatapacksFolder(File worldFolder) {
@@ -255,12 +450,12 @@ public class ServerConfigurator {
return IrisWorldStorage.levelRoot(worldFolder); return IrisWorldStorage.levelRoot(worldFolder);
} }
private static boolean verifyDataPacksPost(boolean allowRestarting) { private static boolean verifyDataPacksPost() {
try (Stream<IrisData> stream = allPacks()) { try (Stream<IrisData> stream = allPacks()) {
boolean bad = stream boolean bad = stream
.map(data -> { .map(data -> {
IrisLogging.debug("Checking Pack: " + data.getDataFolder().getPath()); IrisLogging.debug("Checking Pack: " + data.getDataFolder().getPath());
var loader = data.getDimensionLoader(); ResourceLoader<IrisDimension> loader = data.getDimensionLoader();
return loader.loadAll(loader.getPossibleKeys()) return loader.loadAll(loader.getPossibleKeys())
.stream() .stream()
.filter(Objects::nonNull) .filter(Objects::nonNull)
@@ -270,13 +465,13 @@ public class ServerConfigurator {
}) })
.toList() .toList()
.contains(true); .contains(true);
if (!bad) return false; if (!bad) {
return false;
}
} }
if (allowRestarting) { if (INMS.get().supportsDataPacks()) {
restart();
} else if (INMS.get().supportsDataPacks()) {
IrisLogging.error("============================================================================"); IrisLogging.error("============================================================================");
IrisLogging.error(C.ITALIC + "You need to restart your server to properly generate custom biomes."); IrisLogging.error(C.ITALIC + "You need to restart your server to properly generate custom biomes.");
IrisLogging.error(C.ITALIC + "By continuing, Iris will use backup biomes in place of the custom biomes."); IrisLogging.error(C.ITALIC + "By continuing, Iris will use backup biomes in place of the custom biomes.");
@@ -292,22 +487,23 @@ public class ServerConfigurator {
} }
} }
J.sleep(3000);
} }
return true; return true;
} }
public static void restart() { public static void restart() {
J.s(() -> { restart("New data pack entries have been installed in Iris.");
IrisLogging.warn("New data pack entries have been installed in Iris! Restarting server!"); }
IrisLogging.warn("This will only happen when your pack changes (updates/first time setup)");
IrisLogging.warn("(You can disable this auto restart in iris settings)"); public static void restart(String reason) {
LifecycleOperationCoordinator.get().quiesceForRestart(() -> J.s(() -> {
IrisLogging.warn(reason + " Restarting server to restore a safe lifecycle boundary.");
J.s(() -> { J.s(() -> {
IrisLogging.warn("Looks like the restart command didn't work. Stopping the server instead!"); IrisLogging.warn("Looks like the restart command didn't work. Stopping the server instead!");
Bukkit.shutdown(); Bukkit.shutdown();
}, 100); }, 100);
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "restart"); Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "restart");
}); }));
} }
public static boolean verifyDataPackInstalled(IrisDimension dimension) { public static boolean verifyDataPackInstalled(IrisDimension dimension) {
@@ -360,12 +556,12 @@ public class ServerConfigurator {
} }
public static Stream<IrisData> allPacks() { public static Stream<IrisData> allPacks() {
File[] packs = IrisPlatforms.get().dataFolder("packs").listFiles(File::isDirectory); Stream<File> locals = PackDirectoryResolver.listVisiblePackDirectories(
Stream<File> locals = packs == null ? Stream.empty() : Arrays.stream(packs); IrisPlatforms.get().dataFolder("packs")
).stream();
return Stream.concat(locals return Stream.concat(locals
.filter(base -> !base.getName().contains(".importing-")) .filter(base -> {
.filter( base -> { File[] content = new File(base, "dimensions").listFiles();
var content = new File(base, "dimensions").listFiles();
return content != null && content.length > 0; return content != null && content.length > 0;
}) })
.map(IrisData::get), IrisWorlds.get().getPacks()); .map(IrisData::get), IrisWorlds.get().getPacks());
@@ -0,0 +1,169 @@
package art.arcane.iris.core;
import org.bukkit.NamespacedKey;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
public final class WorldRemovalPathPolicy {
private WorldRemovalPathPolicy() {
}
public static Target resolve(String identifier, String configuredMainWorld, Path levelRoot) {
return resolve(identifier, configuredMainWorld, List.of(configuredMainWorld), levelRoot);
}
public static Target resolve(
String identifier,
String currentMainWorld,
Collection<String> protectedWorldNames,
Path levelRoot
) {
String requestedIdentifier = requireIdentifier(identifier);
String mainWorld = requireIdentifier(currentMainWorld);
for (String protectedWorldName : Objects.requireNonNull(protectedWorldNames, "protectedWorldNames")) {
if (protectedWorldName != null
&& !protectedWorldName.isBlank()
&& requestedIdentifier.equalsIgnoreCase(protectedWorldName.trim())) {
throw new Rejection(RejectionReason.CONFIGURED_MAIN_WORLD,
"A current or configured main world cannot be removed.");
}
}
if (requestedIdentifier.toLowerCase(Locale.ENGLISH).startsWith(NamespacedKey.MINECRAFT + ":")) {
throw new Rejection(RejectionReason.MINECRAFT_NAMESPACE,
"Minecraft namespace worlds cannot be removed.");
}
NamespacedKey worldKey;
try {
worldKey = IrisWorldStorage.managedKeyFromName(requestedIdentifier, mainWorld);
} catch (IllegalArgumentException failure) {
throw classifyIdentifierFailure(requestedIdentifier, failure);
}
Path normalizedLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
if (Files.isSymbolicLink(normalizedLevelRoot)) {
throw new Rejection(RejectionReason.SYMBOLIC_LINK,
"World storage path contains a symbolic link: " + normalizedLevelRoot);
}
Path target;
try {
target = IrisWorldStorage.requireSafeManagedDimensionRoot(
normalizedLevelRoot.toFile(),
worldKey
).toPath().toAbsolutePath().normalize();
} catch (IllegalArgumentException failure) {
throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure);
}
validateStoragePath(normalizedLevelRoot, worldKey, target);
return new Target(
requestedIdentifier,
worldKey,
IrisWorldStorage.logicalName(worldKey, mainWorld),
normalizedLevelRoot,
target
);
}
public static void validateStoragePath(Path levelRoot, NamespacedKey worldKey, Path candidate) {
Path normalizedLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
if (Files.isSymbolicLink(normalizedLevelRoot)) {
throw new Rejection(RejectionReason.SYMBOLIC_LINK,
"World storage path contains a symbolic link: " + normalizedLevelRoot);
}
Path expected;
try {
expected = IrisWorldStorage.requireSafeManagedDimensionRoot(
normalizedLevelRoot.toFile(),
Objects.requireNonNull(worldKey, "worldKey")
).toPath().toAbsolutePath().normalize();
} catch (IllegalArgumentException failure) {
throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure);
}
Path normalizedCandidate = Objects.requireNonNull(candidate, "candidate").toAbsolutePath().normalize();
if (!normalizedCandidate.equals(expected)) {
throw new Rejection(RejectionReason.OUTSIDE_STORAGE_ROOT,
"The world directory is outside its exact Iris dimension storage root.");
}
}
private static Rejection classifyIdentifierFailure(String identifier, IllegalArgumentException failure) {
NamespacedKey parsed = identifier.contains(":")
? NamespacedKey.fromString(identifier.toLowerCase(Locale.ENGLISH))
: null;
RejectionReason reason = parsed != null && !"iris".equals(parsed.getNamespace())
? RejectionReason.NOT_IRIS_NAMESPACE
: RejectionReason.INVALID_IDENTIFIER;
return new Rejection(reason, failure.getMessage(), failure);
}
private static Rejection classifyStorageFailure(
Path levelRoot,
NamespacedKey worldKey,
IllegalArgumentException failure
) {
Path dimensions = levelRoot.resolve("dimensions");
Path namespace = dimensions.resolve(worldKey.getNamespace());
Path target = namespace.resolve(worldKey.getKey());
RejectionReason reason = Files.isSymbolicLink(dimensions)
|| Files.isSymbolicLink(namespace)
|| Files.isSymbolicLink(target)
? RejectionReason.SYMBOLIC_LINK
: RejectionReason.OUTSIDE_STORAGE_ROOT;
return new Rejection(reason, failure.getMessage(), failure);
}
private static String requireIdentifier(String identifier) {
if (identifier == null || identifier.isBlank()) {
throw new Rejection(RejectionReason.INVALID_IDENTIFIER, "The world identifier cannot be empty.");
}
return identifier.trim();
}
public record Target(
String requestedIdentifier,
NamespacedKey worldKey,
String logicalName,
Path levelRoot,
Path worldDirectory
) {
public Target {
Objects.requireNonNull(requestedIdentifier, "requestedIdentifier");
Objects.requireNonNull(worldKey, "worldKey");
Objects.requireNonNull(logicalName, "logicalName");
Objects.requireNonNull(levelRoot, "levelRoot");
Objects.requireNonNull(worldDirectory, "worldDirectory");
}
}
public enum RejectionReason {
INVALID_IDENTIFIER,
CONFIGURED_MAIN_WORLD,
MINECRAFT_NAMESPACE,
NOT_IRIS_NAMESPACE,
OUTSIDE_STORAGE_ROOT,
SYMBOLIC_LINK
}
public static final class Rejection extends IllegalArgumentException {
private final RejectionReason reason;
private Rejection(RejectionReason reason, String message) {
super(message);
this.reason = Objects.requireNonNull(reason, "reason");
}
private Rejection(RejectionReason reason, String message, Throwable cause) {
super(message, cause);
this.reason = Objects.requireNonNull(reason, "reason");
}
public RejectionReason reason() {
return reason;
}
}
}
File diff suppressed because it is too large. Load diff
@@ -23,14 +23,17 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.Reader;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -39,6 +42,7 @@ public final class ModrinthResolver {
private static final String API = "https://api.modrinth.com/v2"; private static final String API = "https://api.modrinth.com/v2";
private static final String USER_AGENT = "VolmitSoftware/Iris (datapack-ingest)"; private static final String USER_AGENT = "VolmitSoftware/Iris (datapack-ingest)";
private static final String DATAPACK_LOADER = "datapack"; private static final String DATAPACK_LOADER = "datapack";
private static final int MAX_API_RESPONSE_CHARS = 8 * 1024 * 1024;
private ModrinthResolver() { private ModrinthResolver() {
} }
@@ -63,9 +67,19 @@ public final class ModrinthResolver {
? selectLatestDatapackVersion(versions, serverMcVersion) ? selectLatestDatapackVersion(versions, serverMcVersion)
: selectVersionByToken(versions, ref.versionToken); : selectVersionByToken(versions, ref.versionToken);
if (version == null) { if (version == null) {
String detail = ref.versionToken == null ? "no datapack-loader version" : "no version matching '" + ref.versionToken + "'"; String detail = ref.versionToken == null
? (serverMcVersion == null || serverMcVersion.isBlank()
? "no datapack-loader version"
: "no datapack-loader version compatible with Minecraft " + serverMcVersion)
: "no version matching '" + ref.versionToken + "'";
throw new IOException("Modrinth project '" + ref.slug + "' has " + detail); throw new IOException("Modrinth project '" + ref.slug + "' has " + detail);
} }
if (!isDatapack(version)) {
throw new IOException("Modrinth version for '" + ref.slug + "' is not published for the datapack loader");
}
if (serverMcVersion != null && !serverMcVersion.isBlank() && !gameVersionsContains(version, serverMcVersion)) {
throw new IOException("Modrinth version for '" + ref.slug + "' is not compatible with Minecraft " + serverMcVersion);
}
JsonObject file = selectFile(version); JsonObject file = selectFile(version);
if (file == null) { if (file == null) {
@@ -76,41 +90,30 @@ public final class ModrinthResolver {
} }
private static ModrinthRef parse(String url) { private static ModrinthRef parse(String url) {
if (!url.toLowerCase(Locale.ROOT).contains("modrinth.com/")) { URI uri;
try {
uri = new URI(url);
} catch (URISyntaxException e) {
return null;
}
String host = uri.getHost();
if (host == null || !(host.equalsIgnoreCase("modrinth.com") || host.equalsIgnoreCase("www.modrinth.com"))) {
return null; return null;
} }
String path = url.replaceFirst("^[a-zA-Z][a-zA-Z0-9+.-]*://", "");
int query = path.indexOf('?');
if (query >= 0) {
path = path.substring(0, query);
}
int fragment = path.indexOf('#');
if (fragment >= 0) {
path = path.substring(0, fragment);
}
List<String> parts = new ArrayList<>(); List<String> parts = new ArrayList<>();
for (String segment : path.split("/")) { for (String segment : uri.getPath().split("/")) {
if (!segment.isBlank()) { if (!segment.isBlank()) {
parts.add(segment); parts.add(segment);
} }
} }
if (parts.size() < 2) {
int base = -1;
for (int i = 0; i < parts.size(); i++) {
if (parts.get(i).equalsIgnoreCase("modrinth.com")) {
base = i;
break;
}
}
if (base < 0 || parts.size() < base + 3) {
return null; return null;
} }
String slug = parts.get(base + 2); String slug = parts.get(1);
String token = null; String token = null;
for (int i = base + 3; i + 1 < parts.size(); i++) { for (int i = 2; i + 1 < parts.size(); i++) {
if (parts.get(i).equalsIgnoreCase("version")) { if (parts.get(i).equalsIgnoreCase("version")) {
token = parts.get(i + 1); token = parts.get(i + 1);
break; break;
@@ -125,6 +128,9 @@ public final class ModrinthResolver {
String normalizedToken = normalizeVersion(token); String normalizedToken = normalizeVersion(token);
for (JsonElement element : versions) { for (JsonElement element : versions) {
if (!element.isJsonObject()) {
continue;
}
JsonObject version = element.getAsJsonObject(); JsonObject version = element.getAsJsonObject();
String id = optString(version, "id"); String id = optString(version, "id");
String number = optString(version, "version_number"); String number = optString(version, "version_number");
@@ -146,25 +152,24 @@ public final class ModrinthResolver {
return datapackMatch != null ? datapackMatch : anyMatch; return datapackMatch != null ? datapackMatch : anyMatch;
} }
private static JsonObject selectLatestDatapackVersion(JsonArray versions, String serverMcVersion) { static JsonObject selectLatestDatapackVersion(JsonArray versions, String serverMcVersion) {
JsonObject firstDatapack = null;
for (JsonElement element : versions) { for (JsonElement element : versions) {
if (!element.isJsonObject()) {
continue;
}
JsonObject version = element.getAsJsonObject(); JsonObject version = element.getAsJsonObject();
if (!isDatapack(version)) { if (!isDatapack(version)) {
continue; continue;
} }
if (firstDatapack == null) { if (serverMcVersion == null || serverMcVersion.isBlank() || gameVersionsContains(version, serverMcVersion)) {
firstDatapack = version;
}
if (serverMcVersion != null && !serverMcVersion.isBlank() && gameVersionsContains(version, serverMcVersion)) {
return version; return version;
} }
} }
return firstDatapack; return null;
} }
private static JsonObject selectFile(JsonObject version) { static JsonObject selectFile(JsonObject version) {
JsonArray files = version.getAsJsonArray("files"); JsonArray files = optArray(version, "files");
if (files == null || files.isEmpty()) { if (files == null || files.isEmpty()) {
return null; return null;
} }
@@ -174,11 +179,14 @@ public final class ModrinthResolver {
JsonObject primary = null; JsonObject primary = null;
JsonObject first = null; JsonObject first = null;
for (JsonElement element : files) { for (JsonElement element : files) {
if (!element.isJsonObject()) {
continue;
}
JsonObject file = element.getAsJsonObject(); JsonObject file = element.getAsJsonObject();
if (first == null) { if (first == null) {
first = file; first = file;
} }
boolean isPrimary = file.has("primary") && file.get("primary").getAsBoolean(); boolean isPrimary = optBoolean(file, "primary");
boolean isZip = optString(file, "filename").toLowerCase(Locale.ROOT).endsWith(".zip"); boolean isZip = optString(file, "filename").toLowerCase(Locale.ROOT).endsWith(".zip");
if (isPrimary && primary == null) { if (isPrimary && primary == null) {
primary = file; primary = file;
@@ -203,14 +211,20 @@ public final class ModrinthResolver {
return first; return first;
} }
private static ResolvedDatapack toResolved(JsonObject version, JsonObject file, String slug) { private static ResolvedDatapack toResolved(JsonObject version, JsonObject file, String slug) throws IOException {
String downloadUrl = optString(file, "url"); String downloadUrl = optString(file, "url");
String filename = optString(file, "filename"); String filename = optString(file, "filename");
if (downloadUrl.isBlank() || filename.isBlank()) {
throw new IOException("Modrinth version for '" + slug + "' has an invalid downloadable file");
}
String sha1 = null; String sha1 = null;
if (file.has("hashes") && file.get("hashes").isJsonObject()) { if (file.has("hashes") && file.get("hashes").isJsonObject()) {
sha1 = optString(file.getAsJsonObject("hashes"), "sha1"); sha1 = optString(file.getAsJsonObject("hashes"), "sha1");
if (!sha1.isBlank() && !sha1.matches("(?i)[0-9a-f]{40}")) {
throw new IOException("Modrinth version for '" + slug + "' has an invalid SHA-1 checksum");
}
} }
return new ResolvedDatapack(downloadUrl, filename, sha1, optString(version, "id"), optString(version, "version_number"), slug); return new ResolvedDatapack(downloadUrl, filename, sha1, optString(version, "id"), optString(version, "version_number"), slug, false);
} }
private static ResolvedDatapack directResolve(String url) { private static ResolvedDatapack directResolve(String url) {
@@ -226,16 +240,45 @@ public final class ModrinthResolver {
if (filename.isBlank()) { if (filename.isBlank()) {
filename = "datapack.zip"; filename = "datapack.zip";
} }
return new ResolvedDatapack(url, filename, null, "direct", "direct", null); String identity = directIdentity(url);
return new ResolvedDatapack(url, filename, null, "direct-" + identity, "direct", null, true);
}
static String directIdentity(String url) {
String normalized = url;
try {
URI parsed = new URI(url).normalize();
normalized = new URI(
parsed.getScheme() == null ? null : parsed.getScheme().toLowerCase(Locale.ROOT),
parsed.getUserInfo(),
parsed.getHost() == null ? null : parsed.getHost().toLowerCase(Locale.ROOT),
parsed.getPort(),
parsed.getPath(),
parsed.getQuery(),
null
).toASCIIString();
} catch (URISyntaxException ignored) {
}
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(normalized.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder(16);
for (int i = 0; i < 8; i++) {
builder.append(String.format("%02x", hash[i]));
}
return builder.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 algorithm unavailable", e);
}
} }
private static boolean isDatapack(JsonObject version) { private static boolean isDatapack(JsonObject version) {
JsonArray loaders = version.getAsJsonArray("loaders"); JsonArray loaders = optArray(version, "loaders");
if (loaders == null) { if (loaders == null) {
return false; return false;
} }
for (JsonElement loader : loaders) { for (JsonElement loader : loaders) {
if (DATAPACK_LOADER.equalsIgnoreCase(loader.getAsString())) { if (isString(loader) && DATAPACK_LOADER.equalsIgnoreCase(loader.getAsString())) {
return true; return true;
} }
} }
@@ -243,12 +286,12 @@ public final class ModrinthResolver {
} }
private static boolean gameVersionsContains(JsonObject version, String mc) { private static boolean gameVersionsContains(JsonObject version, String mc) {
JsonArray gameVersions = version.getAsJsonArray("game_versions"); JsonArray gameVersions = optArray(version, "game_versions");
if (gameVersions == null) { if (gameVersions == null) {
return false; return false;
} }
for (JsonElement gv : gameVersions) { for (JsonElement gv : gameVersions) {
if (mc.equalsIgnoreCase(gv.getAsString())) { if (isString(gv) && mc.equalsIgnoreCase(gv.getAsString())) {
return true; return true;
} }
} }
@@ -267,19 +310,42 @@ public final class ModrinthResolver {
} }
private static String optString(JsonObject object, String key) { private static String optString(JsonObject object, String key) {
if (object == null || !object.has(key) || object.get(key).isJsonNull()) { if (object == null || !object.has(key) || !isString(object.get(key))) {
return ""; return "";
} }
return object.get(key).getAsString(); return object.get(key).getAsString();
} }
private static JsonArray optArray(JsonObject object, String key) {
if (object == null || !object.has(key) || !object.get(key).isJsonArray()) {
return null;
}
return object.getAsJsonArray(key);
}
private static boolean optBoolean(JsonObject object, String key) {
if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()
|| !object.get(key).getAsJsonPrimitive().isBoolean()) {
return false;
}
return object.get(key).getAsBoolean();
}
private static boolean isString(JsonElement element) {
return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString();
}
private static JsonArray getJsonArray(String url) throws IOException { private static JsonArray getJsonArray(String url) throws IOException {
String body = httpGet(url); String body = httpGet(url);
JsonElement parsed = JsonParser.parseString(body); try {
if (!parsed.isJsonArray()) { JsonElement parsed = JsonParser.parseString(body);
throw new IOException("Unexpected response from " + url); if (!parsed.isJsonArray()) {
throw new IOException("Unexpected response from " + url);
}
return parsed.getAsJsonArray();
} catch (RuntimeException e) {
throw new IOException("Invalid JSON response from " + url, e);
} }
return parsed.getAsJsonArray();
} }
private static String httpGet(String url) throws IOException { private static String httpGet(String url) throws IOException {
@@ -298,17 +364,36 @@ public final class ModrinthResolver {
throw new IOException("HTTP " + code + " from " + url); throw new IOException("HTTP " + code + " from " + url);
} }
StringBuilder builder = new StringBuilder();
try (InputStream input = connection.getInputStream(); try (InputStream input = connection.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) { InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) {
String line; return readBoundedApiResponse(reader, url, MAX_API_RESPONSE_CHARS);
while ((line = reader.readLine()) != null) {
builder.append(line);
}
} finally { } finally {
connection.disconnect(); connection.disconnect();
} }
return builder.toString(); }
static String readBoundedApiResponse(Reader reader, String source, int maxChars) throws IOException {
if (maxChars < 1) {
throw new IllegalArgumentException("Response character limit must be positive");
}
int bufferSize = maxChars < 8192 ? maxChars + 1 : 8192;
char[] buffer = new char[bufferSize];
StringBuilder builder = new StringBuilder(Math.min(maxChars, 8192));
while (true) {
int remaining = maxChars - builder.length();
int requested = remaining >= buffer.length ? buffer.length : remaining + 1;
int length = reader.read(buffer, 0, requested);
if (length == -1) {
return builder.toString();
}
if (length == 0) {
continue;
}
if (length > remaining) {
throw new IOException("Oversized response from " + source);
}
builder.append(buffer, 0, length);
}
} }
private static final class ModrinthRef { private static final class ModrinthRef {
@@ -328,14 +413,16 @@ public final class ModrinthResolver {
private final String versionId; private final String versionId;
private final String versionNumber; private final String versionNumber;
private final String projectSlug; private final String projectSlug;
private final boolean direct;
public ResolvedDatapack(String downloadUrl, String fileName, String sha1, String versionId, String versionNumber, String projectSlug) { public ResolvedDatapack(String downloadUrl, String fileName, String sha1, String versionId, String versionNumber, String projectSlug, boolean direct) {
this.downloadUrl = downloadUrl; this.downloadUrl = downloadUrl;
this.fileName = fileName; this.fileName = fileName;
this.sha1 = sha1; this.sha1 = sha1;
this.versionId = versionId; this.versionId = versionId;
this.versionNumber = versionNumber; this.versionNumber = versionNumber;
this.projectSlug = projectSlug; this.projectSlug = projectSlug;
this.direct = direct;
} }
public String getDownloadUrl() { public String getDownloadUrl() {
@@ -361,5 +448,9 @@ public final class ModrinthResolver {
public String getProjectSlug() { public String getProjectSlug() {
return projectSlug; return projectSlug;
} }
public boolean isDirect() {
return direct;
}
} }
} }
@@ -45,8 +45,8 @@ final class BukkitPublicBackend implements WorldLifecycleBackend {
} }
@Override @Override
public boolean unload(World world, boolean save) { public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
return WorldLifecycleSupport.unloadWorld(capabilities, world, save); return WorldLifecycleSupport.unloadWorldAsync(capabilities, world, save);
} }
@Override @Override
@@ -0,0 +1,194 @@
package art.arcane.iris.core.lifecycle;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
import java.io.File;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Objects;
import java.util.ArrayList;
import java.util.function.Predicate;
public final class BukkitWorldConfiguration {
private static final Object MUTATION_LOCK = new Object();
private BukkitWorldConfiguration() {
}
public static Registration register(File configurationFile, String worldName, String dimension, Long seed) throws IOException {
Objects.requireNonNull(configurationFile, "configurationFile");
String requiredWorldName = requireWorldName(worldName);
String requiredDimension = requireName(dimension, "Dimension");
synchronized (MUTATION_LOCK) {
YamlConfiguration configuration = load(configurationFile);
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
if (worlds == null) {
worlds = configuration.createSection("worlds");
}
ConfigurationSection existing = worlds.getConfigurationSection(requiredWorldName);
String generator = "Iris:" + requiredDimension;
if (existing != null) {
String existingGenerator = existing.getString("generator");
Long existingSeed = existing.contains("seed") ? existing.getLong("seed") : null;
if (!generator.equals(existingGenerator) || !Objects.equals(seed, existingSeed)) {
throw new IOException("bukkit.yml already contains a different definition for world \""
+ requiredWorldName + "\".");
}
return Registration.UNCHANGED;
}
ConfigurationSection created = worlds.createSection(requiredWorldName);
created.set("generator", generator);
if (seed != null) {
created.set("seed", seed);
}
saveAtomic(configurationFile.toPath(), configuration);
return Registration.CREATED;
}
}
public static boolean remove(File configurationFile, String worldName) throws IOException {
Objects.requireNonNull(configurationFile, "configurationFile");
String requiredWorldName = requireWorldName(worldName);
synchronized (MUTATION_LOCK) {
YamlConfiguration configuration = load(configurationFile);
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
if (worlds == null || worlds.get(requiredWorldName) == null) {
return false;
}
worlds.set(requiredWorldName, null);
if (worlds.getKeys(false).isEmpty()) {
configuration.set("worlds", null);
}
saveAtomic(configurationFile.toPath(), configuration);
return true;
}
}
public static boolean removeIfMatching(
File configurationFile,
String worldName,
String dimension,
Long seed
) throws IOException {
Objects.requireNonNull(configurationFile, "configurationFile");
String requiredWorldName = requireWorldName(worldName);
String requiredDimension = requireName(dimension, "Dimension");
synchronized (MUTATION_LOCK) {
YamlConfiguration configuration = load(configurationFile);
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
if (worlds == null) {
return false;
}
ConfigurationSection existing = worlds.getConfigurationSection(requiredWorldName);
if (existing == null) {
return false;
}
String expectedGenerator = "Iris:" + requiredDimension;
String actualGenerator = existing.getString("generator");
Long actualSeed = existing.contains("seed") ? existing.getLong("seed") : null;
if (!expectedGenerator.equals(actualGenerator) || !Objects.equals(seed, actualSeed)) {
return false;
}
worlds.set(requiredWorldName, null);
if (worlds.getKeys(false).isEmpty()) {
configuration.set("worlds", null);
}
saveAtomic(configurationFile.toPath(), configuration);
return true;
}
}
public static int removeMatching(File configurationFile, Predicate<String> matcher) throws IOException {
Objects.requireNonNull(configurationFile, "configurationFile");
Predicate<String> requiredMatcher = Objects.requireNonNull(matcher, "matcher");
synchronized (MUTATION_LOCK) {
YamlConfiguration configuration = load(configurationFile);
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
if (worlds == null) {
return 0;
}
int removed = 0;
for (String worldName : new ArrayList<>(worlds.getKeys(false))) {
if (!requiredMatcher.test(worldName)) {
continue;
}
worlds.set(worldName, null);
removed++;
}
if (removed == 0) {
return 0;
}
if (worlds.getKeys(false).isEmpty()) {
configuration.set("worlds", null);
}
saveAtomic(configurationFile.toPath(), configuration);
return removed;
}
}
static void saveAtomic(Path target, YamlConfiguration configuration) throws IOException {
Path absoluteTarget = target.toAbsolutePath().normalize();
Path parent = absoluteTarget.getParent();
if (parent == null) {
throw new IOException("bukkit.yml target has no parent: " + absoluteTarget);
}
Files.createDirectories(parent);
Path staged = Files.createTempFile(parent, ".bukkit-worlds-", ".yml");
try {
configuration.save(staged.toFile());
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
channel.force(true);
}
try {
Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(staged);
}
}
private static YamlConfiguration load(File configurationFile) throws IOException {
YamlConfiguration configuration = new YamlConfiguration();
try {
configuration.load(configurationFile);
return configuration;
} catch (InvalidConfigurationException exception) {
throw new IOException("bukkit.yml is invalid and was not changed.", exception);
}
}
private static String requireWorldName(String value) {
String worldName = requireName(value, "World name");
if (!worldName.matches("[a-z0-9_-]+")) {
throw new IllegalArgumentException("World name must contain only lowercase letters, numbers, underscores, or hyphens.");
}
return worldName;
}
private static String requireName(String value, String label) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(label + " cannot be empty.");
}
return value.trim();
}
public enum Registration {
CREATED,
UNCHANGED
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,275 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.spi.IrisLogging;
import java.util.ArrayDeque;
import java.util.Collections;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
public final class LifecycleOperationCoordinator {
private static final LifecycleOperationCoordinator INSTANCE = new LifecycleOperationCoordinator();
private final Object monitor;
private final ArrayDeque<Runnable> idleCallbacks;
private ActiveOperation mutationOperation;
private ActiveOperation restartOperation;
private Runnable pendingRestartCallback;
private long nextOperationId;
LifecycleOperationCoordinator() {
monitor = new Object();
idleCallbacks = new ArrayDeque<>();
nextOperationId = 1L;
}
public static LifecycleOperationCoordinator get() {
return INSTANCE;
}
public Lease acquire(Domain domain, OperationKind kind, String target) {
Objects.requireNonNull(domain, "domain");
Objects.requireNonNull(kind, "kind");
String normalizedTarget = Objects.requireNonNull(target, "target").trim();
if (normalizedTarget.isEmpty()) {
throw new IllegalArgumentException("target must not be blank");
}
synchronized (monitor) {
if (domain == Domain.SERVER_LIFECYCLE) {
throw new IllegalArgumentException("SERVER_LIFECYCLE is reserved for terminal operations");
}
ActiveOperation currentOperation = restartOperation == null ? mutationOperation : restartOperation;
if (currentOperation != null) {
throw new BusyException(currentOperation);
}
ActiveOperation operation = new ActiveOperation(nextOperationId++, domain, kind, normalizedTarget);
mutationOperation = operation;
return new LeaseImpl(this, operation);
}
}
public boolean quiesceForRestart(Runnable callback) {
Runnable restartCallback = Objects.requireNonNull(callback, "callback");
ActiveOperation operation;
boolean dispatch;
synchronized (monitor) {
if (restartOperation != null) {
return false;
}
operation = new ActiveOperation(
nextOperationId++,
Domain.SERVER_LIFECYCLE,
OperationKind.SERVER_RESTART,
"server");
restartOperation = operation;
dispatch = mutationOperation == null;
if (!dispatch) {
pendingRestartCallback = restartCallback;
return true;
}
}
runRestartCallback(restartCallback, operation);
return true;
}
public Optional<ActiveOperation> active(Domain domain) {
Objects.requireNonNull(domain, "domain");
synchronized (monitor) {
if (domain == Domain.SERVER_LIFECYCLE) {
return Optional.ofNullable(restartOperation);
}
if (mutationOperation != null && mutationOperation.domain() == domain) {
return Optional.of(mutationOperation);
}
return Optional.empty();
}
}
public Map<Domain, ActiveOperation> snapshot() {
synchronized (monitor) {
EnumMap<Domain, ActiveOperation> operations = new EnumMap<>(Domain.class);
if (mutationOperation != null) {
operations.put(mutationOperation.domain(), mutationOperation);
}
if (restartOperation != null) {
operations.put(Domain.SERVER_LIFECYCLE, restartOperation);
}
return Collections.unmodifiableMap(operations);
}
}
public boolean isIdle() {
synchronized (monitor) {
return mutationOperation == null && restartOperation == null;
}
}
public void whenIdle(Runnable callback) {
Runnable idleCallback = Objects.requireNonNull(callback, "callback");
synchronized (monitor) {
if (mutationOperation != null || restartOperation != null) {
idleCallbacks.addLast(idleCallback);
return;
}
runIdleCallback(idleCallback);
}
}
private void release(ActiveOperation operation) {
Runnable restartCallback = null;
ActiveOperation terminalOperation = null;
synchronized (monitor) {
if (!operation.equals(mutationOperation)) {
throw new IllegalStateException("Lifecycle operation lease is not active: " + operation.id());
}
mutationOperation = null;
if (restartOperation != null && pendingRestartCallback != null) {
terminalOperation = restartOperation;
restartCallback = pendingRestartCallback;
pendingRestartCallback = null;
} else {
runIdleCallbacks();
}
}
if (restartCallback != null) {
runRestartCallback(restartCallback, terminalOperation);
}
}
private void runIdleCallbacks() {
while (mutationOperation == null && restartOperation == null && !idleCallbacks.isEmpty()) {
runIdleCallback(idleCallbacks.removeFirst());
}
}
private void runIdleCallback(Runnable callback) {
try {
callback.run();
} catch (Throwable failure) {
IrisLogging.reportError("Lifecycle idle callback failed.", failure);
}
}
private void runRestartCallback(Runnable callback, ActiveOperation operation) {
try {
callback.run();
} catch (Throwable failure) {
IrisLogging.reportError("Lifecycle restart dispatch failed for operation " + operation.id() + ".", failure);
}
}
public enum Domain {
WORLD_MUTATION,
PACK_MUTATION,
SERVER_LIFECYCLE
}
public enum OperationKind {
WORLD_CREATE,
WORLD_LOAD,
WORLD_UNLOAD,
WORLD_REMOVE,
WORLD_PROMOTE,
STUDIO_OPEN,
STUDIO_CLOSE,
PACK_CREATE,
PACK_DOWNLOAD,
PACK_PUBLISH,
DATAPACK_COMPILE,
SERVER_RESTART
}
public record ActiveOperation(long id, Domain domain, OperationKind kind, String target) {
public ActiveOperation {
if (id < 1L) {
throw new IllegalArgumentException("id must be positive");
}
Objects.requireNonNull(domain, "domain");
Objects.requireNonNull(kind, "kind");
target = Objects.requireNonNull(target, "target").trim();
if (target.isEmpty()) {
throw new IllegalArgumentException("target must not be blank");
}
}
}
public static final class BusyException extends IllegalStateException {
private final ActiveOperation currentOperation;
private BusyException(ActiveOperation currentOperation) {
super("Lifecycle domain " + currentOperation.domain()
+ " is busy with " + currentOperation.kind()
+ " target=" + currentOperation.target()
+ " id=" + currentOperation.id());
this.currentOperation = currentOperation;
}
public ActiveOperation currentOperation() {
return currentOperation;
}
public long operationId() {
return currentOperation.id();
}
public Domain domain() {
return currentOperation.domain();
}
public OperationKind operationKind() {
return currentOperation.kind();
}
public String target() {
return currentOperation.target();
}
}
public interface Lease extends AutoCloseable {
ActiveOperation operation();
boolean isClosed();
@Override
void close();
}
private static final class LeaseImpl implements Lease {
private final LifecycleOperationCoordinator coordinator;
private final ActiveOperation operation;
private final AtomicBoolean closed;
private LeaseImpl(LifecycleOperationCoordinator coordinator, ActiveOperation operation) {
this.coordinator = coordinator;
this.operation = operation;
closed = new AtomicBoolean();
}
@Override
public ActiveOperation operation() {
return operation;
}
@Override
public boolean isClosed() {
return closed.get();
}
@Override
public void close() {
if (closed.compareAndSet(false, true)) {
coordinator.release(operation);
}
}
}
}
@@ -81,8 +81,8 @@ final class PaperLikeRuntimeBackend implements WorldLifecycleBackend {
} }
@Override @Override
public boolean unload(World world, boolean save) { public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
return WorldLifecycleSupport.unloadWorld(capabilities, world, save); return WorldLifecycleSupport.unloadWorldAsync(capabilities, world, save);
} }
@Override @Override
@@ -9,7 +9,7 @@ public interface WorldLifecycleBackend {
CompletableFuture<World> create(WorldLifecycleRequest request); CompletableFuture<World> create(WorldLifecycleRequest request);
boolean unload(World world, boolean save); CompletableFuture<Boolean> unloadAsync(World world, boolean save);
String backendName(); String backendName();
} }
@@ -1,6 +1,8 @@
package art.arcane.iris.core.lifecycle; package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.util.common.scheduling.J; import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.bukkit.WorldIdentity; import art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
@@ -8,25 +10,43 @@ import org.bukkit.World;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class WorldLifecycleService { public final class WorldLifecycleService {
private static final long UNLOAD_TIMEOUT_SECONDS = 120L;
private static volatile WorldLifecycleService instance; private static volatile WorldLifecycleService instance;
private final CapabilitySnapshot capabilities; private final CapabilitySnapshot capabilities;
private final WorldsProviderBackend worldsProviderBackend; private final WorldLifecycleBackend worldsProviderBackend;
private final PaperLikeRuntimeBackend paperLikeRuntimeBackend; private final WorldLifecycleBackend paperLikeRuntimeBackend;
private final BukkitPublicBackend bukkitPublicBackend; private final WorldLifecycleBackend bukkitPublicBackend;
private final List<WorldLifecycleBackend> backends; private final List<WorldLifecycleBackend> backends;
private final Map<String, String> worldBackendByKey; private final Map<String, String> worldBackendByKey;
public WorldLifecycleService(CapabilitySnapshot capabilities) { public WorldLifecycleService(CapabilitySnapshot capabilities) {
this.capabilities = capabilities; this(
this.worldsProviderBackend = new WorldsProviderBackend(capabilities); capabilities,
this.paperLikeRuntimeBackend = new PaperLikeRuntimeBackend(capabilities); new WorldsProviderBackend(capabilities),
this.bukkitPublicBackend = new BukkitPublicBackend(capabilities); new PaperLikeRuntimeBackend(capabilities),
new BukkitPublicBackend(capabilities)
);
}
WorldLifecycleService(
CapabilitySnapshot capabilities,
WorldLifecycleBackend worldsProviderBackend,
WorldLifecycleBackend paperLikeRuntimeBackend,
WorldLifecycleBackend bukkitPublicBackend
) {
this.capabilities = Objects.requireNonNull(capabilities, "capabilities");
this.worldsProviderBackend = Objects.requireNonNull(worldsProviderBackend, "worldsProviderBackend");
this.paperLikeRuntimeBackend = Objects.requireNonNull(paperLikeRuntimeBackend, "paperLikeRuntimeBackend");
this.bukkitPublicBackend = Objects.requireNonNull(bukkitPublicBackend, "bukkitPublicBackend");
this.backends = List.of(worldsProviderBackend, paperLikeRuntimeBackend, bukkitPublicBackend); this.backends = List.of(worldsProviderBackend, paperLikeRuntimeBackend, bukkitPublicBackend);
this.worldBackendByKey = new ConcurrentHashMap<>(); this.worldBackendByKey = new ConcurrentHashMap<>();
} }
@@ -89,47 +109,83 @@ public final class WorldLifecycleService {
} }
} }
public boolean unload(World world, boolean save) { public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
if (!J.isPrimaryThread()) { World requiredWorld = Objects.requireNonNull(world, "world");
CompletableFuture<Boolean> future = new CompletableFuture<>(); String worldIdentity = WorldIdentity.serialize(requiredWorld);
J.s(() -> { String worldName = requiredWorld.getName();
try {
future.complete(unloadDirect(world, save));
} catch (Throwable e) {
future.completeExceptionally(e);
}
});
return future.join();
}
return unloadDirect(world, save);
}
private boolean unloadDirect(World world, boolean save) {
String worldIdentity = WorldIdentity.serialize(world);
WorldLifecycleBackend backend = selectUnloadBackend(worldIdentity); WorldLifecycleBackend backend = selectUnloadBackend(worldIdentity);
IrisLogging.info("WorldLifecycle unload: world=%s, backend=%s", IrisLogging.info("WorldLifecycle unload: world=%s, backend=%s",
world.getName(), worldName,
backend.backendName()); backend.backendName());
boolean unloaded;
CompletableFuture<Boolean> unloadFuture;
try { try {
unloaded = backend.unload(world, save); unloadFuture = backend.unloadAsync(requiredWorld, save);
if (unloadFuture == null) {
throw new IllegalStateException("World lifecycle backend returned no unload completion future.");
}
} catch (Throwable e) { } catch (Throwable e) {
IrisLogging.reportError("WorldLifecycle unload failed: world=\"" + world.getName() unloadFuture = CompletableFuture.failedFuture(e);
+ "\", backend=" + backend.backendName() }
+ ", family=" + capabilities.serverFamily().id() + ".", e);
if (e instanceof RuntimeException runtimeException) { CompletableFuture<Boolean> guardedFuture = guardUnloadCompletion(worldName, unloadFuture);
return guardedFuture.whenComplete((unloaded, throwable) -> {
if (throwable != null) {
Throwable cause = WorldLifecycleSupport.unwrap(throwable);
IrisLogging.reportError("WorldLifecycle unload failed: world=\"" + worldName
+ "\", backend=" + backend.backendName()
+ ", family=" + capabilities.serverFamily().id() + ".", cause);
return;
}
if (Boolean.TRUE.equals(unloaded)) {
worldBackendByKey.remove(worldIdentity, backend.backendName());
}
});
}
private CompletableFuture<Boolean> guardUnloadCompletion(
String worldName,
CompletableFuture<Boolean> unloadFuture
) {
CompletableFuture<Boolean> guarded = new CompletableFuture<>();
unloadFuture.whenComplete((unloaded, throwable) -> {
if (throwable == null) {
guarded.complete(Boolean.TRUE.equals(unloaded));
} else {
guarded.completeExceptionally(WorldLifecycleSupport.unwrap(throwable));
}
});
if (!guarded.isDone()) {
CompletableFuture.delayedExecutor(UNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> {
TimeoutException timeout = new TimeoutException(
"World unload did not settle within " + UNLOAD_TIMEOUT_SECONDS + " seconds for \""
+ worldName + "\".");
if (!guarded.completeExceptionally(timeout) || IrisToolbelt.isServerStopping()) {
return;
}
ServerConfigurator.restart("World unload timed out for \"" + worldName + "\".");
});
}
return guarded;
}
public boolean unload(World world, boolean save) {
if (J.isPrimaryThread() || (J.isFolia() && WorldLifecycleSupport.isGlobalTickThread())) {
throw new IllegalStateException("WorldLifecycle unload cannot block the primary/global tick thread; use unloadAsync instead.");
}
try {
return Boolean.TRUE.equals(unloadAsync(world, save).join());
} catch (CompletionException e) {
Throwable cause = WorldLifecycleSupport.unwrap(e);
if (cause instanceof RuntimeException runtimeException) {
throw runtimeException; throw runtimeException;
} }
if (e instanceof Error error) { if (cause instanceof Error error) {
throw error; throw error;
} }
throw new IllegalStateException(e); throw new IllegalStateException(cause);
} }
if (unloaded) {
worldBackendByKey.remove(worldIdentity);
}
return unloaded;
} }
public String backendNameForWorld(NamespacedKey worldKey) { public String backendNameForWorld(NamespacedKey worldKey) {
@@ -5,8 +5,10 @@ import art.arcane.iris.core.link.Identifier;
import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.nms.INMSBinding; import art.arcane.iris.core.nms.INMSBinding;
import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.util.common.scheduling.J; import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.bukkit.WorldIdentity; import art.arcane.volmlib.util.bukkit.WorldIdentity;
import art.arcane.volmlib.util.scheduling.FoliaScheduler;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
@@ -414,36 +416,83 @@ final class WorldLifecycleSupport {
} }
} }
static boolean unloadWorld(CapabilitySnapshot capabilities, World world, boolean save) { static CompletableFuture<Boolean> unloadWorldAsync(CapabilitySnapshot capabilities, World world, boolean save) {
if (world == null) { if (world == null) {
return false; return CompletableFuture.completedFuture(false);
} }
CompletableFuture<Boolean> asyncUnload = unloadWorldViaAsyncApi(capabilities, world, save); CompletableFuture<Boolean> result = new CompletableFuture<>();
if (asyncUnload != null) { Runnable invokeTask = () -> beginUnload(capabilities, world, save, result);
return resolveAsyncUnload(asyncUnload); boolean folia = J.isFolia();
if ((!folia && J.isPrimaryThread()) || (folia && isGlobalTickThread())) {
invokeTask.run();
return result;
} }
try { CompletableFuture<Void> scheduled = runGlobalAsync(invokeTask);
return Bukkit.unloadWorld(world, save); scheduled.whenComplete((unused, throwable) -> {
} catch (UnsupportedOperationException unsupported) { if (throwable != null) {
if (capabilities.minecraftServer() == null || capabilities.removeLevelMethod() == null) { result.completeExceptionally(unwrap(throwable));
throw unsupported;
} }
});
return result;
}
private static void beginUnload(
CapabilitySnapshot capabilities,
World world,
boolean save,
CompletableFuture<Boolean> result
) {
CompletableFuture<Boolean> operation;
try {
operation = unloadWorldViaAsyncApi(capabilities, world, save);
if (operation == null) {
operation = unloadWorldWithoutAsyncApi(capabilities, world, save);
}
} catch (Throwable e) {
result.completeExceptionally(unwrap(e));
return;
} }
operation.whenComplete((unloaded, throwable) -> {
if (throwable == null) {
result.complete(Boolean.TRUE.equals(unloaded));
} else {
result.completeExceptionally(unwrap(throwable));
}
});
}
private static CompletableFuture<Boolean> unloadWorldWithoutAsyncApi(
CapabilitySnapshot capabilities,
World world,
boolean save
) {
String worldName = world.getName();
try { try {
try {
return CompletableFuture.completedFuture(Bukkit.unloadWorld(world, save));
} catch (UnsupportedOperationException unsupported) {
if (capabilities.minecraftServer() == null || capabilities.removeLevelMethod() == null) {
return CompletableFuture.failedFuture(unsupported);
}
}
if (save) { if (save) {
world.save(); world.save();
} }
Method getHandleMethod = world.getClass().getMethod("getHandle"); Method getHandleMethod = world.getClass().getMethod("getHandle");
Object serverLevel = getHandleMethod.invoke(world); Object serverLevel = getHandleMethod.invoke(world);
closeServerLevel(world, serverLevel); CompletableFuture<Boolean> operation = closeServerLevelAsync(world, serverLevel)
detachServerLevel(capabilities, serverLevel, world); .thenCompose(unused -> detachServerLevelAsync(capabilities, serverLevel, world))
return WorldIdentity.resolve(WorldIdentity.key(world)).isEmpty(); .thenApply(unused -> WorldIdentity.resolve(WorldIdentity.key(world)).isEmpty());
return contextualizeUnloadFailure(worldName, operation);
} catch (Throwable e) { } catch (Throwable e) {
throw new IllegalStateException("Failed to unload world \"" + world.getName() + "\" through the selected world lifecycle backend.", unwrap(e)); return CompletableFuture.failedFuture(new IllegalStateException(
"Failed to unload world \"" + worldName + "\" through the selected world lifecycle backend.",
unwrap(e)
));
} }
} }
@@ -452,63 +501,70 @@ final class WorldLifecycleSupport {
return null; return null;
} }
return invokeAsyncUnload(
capabilities.bukkitServer(),
capabilities.unloadWorldAsyncMethod(),
world,
save
);
}
static CompletableFuture<Boolean> invokeAsyncUnload(
Object bukkitServer,
Method unloadWorldAsyncMethod,
World world,
boolean save
) {
CompletableFuture<Boolean> callbackFuture = new CompletableFuture<>(); CompletableFuture<Boolean> callbackFuture = new CompletableFuture<>();
Runnable invokeTask = () -> { Consumer<Boolean> callback = unloaded -> callbackFuture.complete(Boolean.TRUE.equals(unloaded));
Consumer<Boolean> callback = result -> callbackFuture.complete(Boolean.TRUE.equals(result)); try {
try { unloadWorldAsyncMethod.invoke(bukkitServer, world, save, callback);
capabilities.unloadWorldAsyncMethod().invoke(capabilities.bukkitServer(), world, save, callback); } catch (Throwable e) {
} catch (Throwable e) { callbackFuture.completeExceptionally(unwrap(e));
callbackFuture.completeExceptionally(unwrap(e));
}
};
if (J.isFolia() && !isGlobalTickThread()) {
CompletableFuture<Void> scheduled = J.sfut(invokeTask);
if (scheduled == null) {
callbackFuture.completeExceptionally(new IllegalStateException("Failed to schedule global unload task."));
return callbackFuture;
}
scheduled.whenComplete((unused, throwable) -> {
if (throwable != null) {
callbackFuture.completeExceptionally(unwrap(throwable));
}
});
return callbackFuture;
} }
invokeTask.run();
return callbackFuture; return callbackFuture;
} }
private static boolean resolveAsyncUnload(CompletableFuture<Boolean> asyncUnload) { private static CompletableFuture<Boolean> contextualizeUnloadFailure(
if (J.isPrimaryThread()) { String worldName,
if (!asyncUnload.isDone()) { CompletableFuture<Boolean> operation
return true; ) {
CompletableFuture<Boolean> result = new CompletableFuture<>();
operation.whenComplete((unloaded, throwable) -> {
if (throwable == null) {
result.complete(Boolean.TRUE.equals(unloaded));
} else {
result.completeExceptionally(new IllegalStateException(
"Failed to unload world \"" + worldName + "\" through the selected world lifecycle backend.",
unwrap(throwable)
));
} }
});
try { return result;
return Boolean.TRUE.equals(asyncUnload.join());
} catch (Throwable e) {
throw new IllegalStateException("Failed to consume async world unload result.", unwrap(e));
}
}
try {
return Boolean.TRUE.equals(asyncUnload.get(120, TimeUnit.SECONDS));
} catch (Throwable e) {
throw new IllegalStateException("Failed while waiting for async world unload result.", unwrap(e));
}
} }
private static void closeServerLevel(World world, Object serverLevel) throws Throwable { private static CompletableFuture<Void> closeServerLevelAsync(World world, Object serverLevel) {
Method closeMethod = CapabilityResolution.resolveMethod(serverLevel.getClass(), "close", method -> method.getParameterCount() == 0); Method closeMethod;
try {
closeMethod = CapabilityResolution.resolveMethod(
serverLevel.getClass(),
"close",
method -> method.getParameterCount() == 0
);
} catch (Throwable e) {
return CompletableFuture.failedFuture(unwrap(e));
}
if (closeMethod == null) { if (closeMethod == null) {
return; return CompletableFuture.completedFuture(null);
} }
if (!J.isFolia()) { if (!J.isFolia()) {
closeMethod.invoke(serverLevel); try {
return; closeMethod.invoke(serverLevel);
return CompletableFuture.completedFuture(null);
} catch (Throwable e) {
return CompletableFuture.failedFuture(unwrap(e));
}
} }
Location spawn = world.getSpawnLocation(); Location spawn = world.getSpawnLocation();
@@ -524,9 +580,11 @@ final class WorldLifecycleSupport {
} }
}); });
if (!scheduled) { if (!scheduled) {
throw new IllegalStateException("Failed to schedule region close task for world \"" + world.getName() + "\"."); return CompletableFuture.failedFuture(new IllegalStateException(
"Failed to schedule region close task for world \"" + world.getName() + "\"."
));
} }
closeFuture.get(90, TimeUnit.SECONDS); return closeFuture.orTimeout(90L, TimeUnit.SECONDS);
} }
@SuppressWarnings({"rawtypes", "unchecked"}) @SuppressWarnings({"rawtypes", "unchecked"})
@@ -545,7 +603,11 @@ final class WorldLifecycleSupport {
} }
} }
private static void detachServerLevel(CapabilitySnapshot capabilities, Object serverLevel, World world) throws Throwable { private static CompletableFuture<Void> detachServerLevelAsync(
CapabilitySnapshot capabilities,
Object serverLevel,
World world
) {
Runnable detachTask = () -> { Runnable detachTask = () -> {
try { try {
capabilities.removeLevelMethod().invoke(capabilities.minecraftServer(), serverLevel); capabilities.removeLevelMethod().invoke(capabilities.minecraftServer(), serverLevel);
@@ -556,15 +618,41 @@ final class WorldLifecycleSupport {
}; };
if (!J.isFolia() || isGlobalTickThread()) { if (!J.isFolia() || isGlobalTickThread()) {
detachTask.run(); try {
return; detachTask.run();
return CompletableFuture.completedFuture(null);
} catch (Throwable e) {
return CompletableFuture.failedFuture(unwrap(e));
}
} }
CompletableFuture<Void> detachFuture = J.sfut(detachTask); CompletableFuture<Void> detachFuture = runGlobalAsync(detachTask);
if (detachFuture == null) { return detachFuture.orTimeout(15L, TimeUnit.SECONDS);
throw new IllegalStateException("Failed to schedule global detach task for world \"" + world.getName() + "\"."); }
private static CompletableFuture<Void> runGlobalAsync(Runnable task) {
if (!J.isFolia()) {
return J.sfut(task);
} }
detachFuture.get(15, TimeUnit.SECONDS);
CompletableFuture<Void> result = new CompletableFuture<>();
Runnable settlement = () -> {
try {
task.run();
result.complete(null);
} catch (Throwable e) {
result.completeExceptionally(unwrap(e));
}
};
try {
if (!FoliaScheduler.runGlobal(BukkitPlatform.plugin(), settlement)) {
result.completeExceptionally(new IllegalStateException("Failed to schedule global world lifecycle task."));
}
} catch (Throwable e) {
result.completeExceptionally(unwrap(e));
}
return result;
} }
static boolean isGlobalTickThread() { static boolean isGlobalTickThread() {
@@ -46,8 +46,8 @@ final class WorldsProviderBackend implements WorldLifecycleBackend {
} }
@Override @Override
public boolean unload(World world, boolean save) { public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
return WorldLifecycleSupport.unloadWorld(capabilities, world, save); return WorldLifecycleSupport.unloadWorldAsync(capabilities, world, save);
} }
@Override @Override
@@ -22,28 +22,36 @@ import lombok.SneakyThrows;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.World; import org.bukkit.World;
import org.mvplugins.multiverse.core.MultiverseCoreApi; import org.mvplugins.multiverse.core.MultiverseCoreApi;
import org.mvplugins.multiverse.core.utils.result.Attempt;
import org.mvplugins.multiverse.core.world.MultiverseWorld; import org.mvplugins.multiverse.core.world.MultiverseWorld;
import org.mvplugins.multiverse.core.world.WorldManager; import org.mvplugins.multiverse.core.world.WorldManager;
import org.mvplugins.multiverse.core.world.options.ImportWorldOptions; import org.mvplugins.multiverse.core.world.options.ImportWorldOptions;
import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions; import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions;
import org.mvplugins.multiverse.core.world.reasons.RemoveFailureReason;
import java.lang.reflect.Field; import java.lang.reflect.Field;
public class MultiverseCoreLink { public class MultiverseCoreLink {
public void removeFromConfig(World world) { public boolean removeFromConfig(World world) {
removeFromConfig(world.getName()); return removeFromConfig(world.getName());
} }
public void removeFromConfig(String world) { public boolean removeFromConfig(String world) {
if (!isActive()) { if (!isActive()) {
return; return false;
} }
WorldManager manager = worldManager(); WorldManager manager = worldManager();
MultiverseWorld multiverseWorld = manager.getWorld(world).getOrElse((MultiverseWorld) null); MultiverseWorld multiverseWorld = manager.getWorld(world).getOrElse((MultiverseWorld) null);
if (multiverseWorld == null) { if (multiverseWorld == null) {
return; return false;
} }
manager.removeWorld(RemoveWorldOptions.world(multiverseWorld)).onSuccess(ignored -> manager.saveWorldsConfig()); Attempt<String, RemoveFailureReason> removal = manager.removeWorld(RemoveWorldOptions.world(multiverseWorld));
if (removal.isFailure()) {
throw new IllegalStateException("Multiverse refused to remove world \"" + world + "\": "
+ removal.getFailureMessage());
}
manager.saveWorldsConfig().get();
return true;
} }
@SneakyThrows @SneakyThrows
@@ -72,7 +80,7 @@ public class MultiverseCoreLink {
.invoke(config, generator); .invoke(config, generator);
} }
manager.saveWorldsConfig(); manager.saveWorldsConfig().get();
} }
private WorldManager worldManager() { private WorldManager worldManager() {
@@ -31,6 +31,7 @@ import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter; import com.google.gson.stream.JsonWriter;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.data.cache.AtomicCache;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.IrisStructureLocator;
@@ -244,15 +245,14 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
} }
} }
for (File i : Objects.requireNonNull(IrisPlatforms.get().dataFolder("packs").listFiles())) { for (File i : PackDirectoryResolver.listVisiblePackDirectories(
if (i.isDirectory()) { IrisPlatforms.get().dataFolder("packs"))) {
IrisData dm = get(i); IrisData dm = get(i);
if (dm == nearest) continue; if (dm == nearest) continue;
T t = dm.load(type, key, false); T t = dm.load(type, key, false);
if (t != null) { if (t != null) {
return t; return t;
}
} }
} }
} catch (Throwable e) { } catch (Throwable e) {
@@ -28,6 +28,7 @@ import art.arcane.iris.engine.data.chunk.TerrainChunk;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.iris.util.project.hunk.Hunk;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.collection.KMap;
@@ -106,6 +107,18 @@ public interface INMSBinding {
return new KList<>(); return new KList<>();
} }
default JigsawSourceMetadata getJigsawSourceMetadata(String structureKey) {
throw new UnsupportedOperationException("The active NMS binding does not expose registered jigsaw metadata");
}
default int getTemplatePoolHorizontalSpan(String templatePoolKey) {
throw new UnsupportedOperationException("The active NMS binding does not expose registered template pool spans");
}
default int getJigsawStartPoolHorizontalSpan(String structureKey, String templatePoolKey) {
return getTemplatePoolHorizontalSpan(templatePoolKey);
}
KList<String> getStructureSetKeys(); KList<String> getStructureSetKeys();
KList<String> getReachableStructureKeys(World world); KList<String> getReachableStructureKeys(World world);
@@ -6,7 +6,7 @@ import java.lang.reflect.Method;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
final class MinecraftVersion { public final class MinecraftVersion {
private static final Pattern DECORATED_VERSION_PATTERN = Pattern.compile("\\(MC: ([0-9]+(?:\\.[0-9]+){0,2})\\)"); private static final Pattern DECORATED_VERSION_PATTERN = Pattern.compile("\\(MC: ([0-9]+(?:\\.[0-9]+){0,2})\\)");
private final String value; private final String value;
@@ -0,0 +1,113 @@
package art.arcane.iris.core.pack;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Stream;
public final class AtomicDirectoryPublisher {
private AtomicDirectoryPublisher() {
}
public static Publication publish(Path stagedDirectory, Path targetDirectory) throws IOException {
Path staged = Objects.requireNonNull(stagedDirectory, "stagedDirectory").toAbsolutePath().normalize();
Path target = Objects.requireNonNull(targetDirectory, "targetDirectory").toAbsolutePath().normalize();
if (!Files.isDirectory(staged) || Files.isSymbolicLink(staged)) {
throw new IOException("Staged directory is missing or unsafe: " + staged);
}
if (!Objects.equals(staged.getParent(), target.getParent())) {
throw new IOException("Staged and target directories must have the same parent.");
}
Path backup = null;
if (Files.exists(target) || Files.isSymbolicLink(target)) {
backup = target.resolveSibling("." + target.getFileName() + ".backup-" + UUID.randomUUID());
move(target, backup);
}
try {
move(staged, target);
return new Publication(target, backup);
} catch (IOException failure) {
if (backup != null && (Files.exists(backup) || Files.isSymbolicLink(backup))) {
try {
move(backup, target);
} catch (IOException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
throw failure;
}
}
private static void move(Path source, Path target) throws IOException {
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(source, target);
}
}
public static final class Publication implements AutoCloseable {
private final Path target;
private final Path backup;
private boolean committed;
private boolean closed;
private Publication(Path target, Path backup) {
this.target = target;
this.backup = backup;
}
public synchronized void commit() {
if (closed) {
throw new IllegalStateException("Directory publication is already closed.");
}
committed = true;
}
public synchronized void cleanupBackup() throws IOException {
if (!committed) {
throw new IllegalStateException("Directory publication is not committed.");
}
if (backup != null) {
deleteTree(backup);
}
}
@Override
public synchronized void close() throws IOException {
if (closed) {
return;
}
closed = true;
if (committed) {
return;
}
deleteTree(target);
if (backup != null && (Files.exists(backup) || Files.isSymbolicLink(backup))) {
move(backup, target);
}
}
}
public static void deleteTree(Path path) throws IOException {
if (!Files.exists(path) && !Files.isSymbolicLink(path)) {
return;
}
if (Files.isSymbolicLink(path) || !Files.isDirectory(path)) {
Files.delete(path);
return;
}
try (Stream<Path> stream = Files.walk(path)) {
for (Path entry : stream.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(entry);
}
}
}
}
@@ -1,9 +1,16 @@
package art.arcane.iris.core.pack; package art.arcane.iris.core.pack;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.LinkOption; import java.nio.file.LinkOption;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Stream;
public final class PackDirectoryResolver { public final class PackDirectoryResolver {
private PackDirectoryResolver() { private PackDirectoryResolver() {
@@ -18,9 +25,103 @@ public final class PackDirectoryResolver {
if (!root.equals(candidate.getParent())) { if (!root.equals(candidate.getParent())) {
return null; return null;
} }
if (Files.isSymbolicLink(candidate) || !Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS)) { if (!isVisiblePackDirectory(candidate.toFile())) {
return null; return null;
} }
return candidate.toFile(); return candidate.toFile();
} }
public static List<File> listVisiblePackDirectories(File packsRoot) {
try {
return listVisiblePackDirectoriesOrThrow(packsRoot);
} catch (IOException exception) {
return List.of();
}
}
public static List<File> listVisiblePackDirectoriesOrThrow(File packsRoot) throws IOException {
if (packsRoot == null) {
return List.of();
}
Path root = packsRoot.toPath().toAbsolutePath().normalize();
if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) {
return List.of();
}
if (Files.isSymbolicLink(root) || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Pack workspace is missing or unsafe: " + root);
}
try (Stream<Path> stream = Files.list(root)) {
return stream
.map(Path::toFile)
.filter(PackDirectoryResolver::isVisiblePackDirectory)
.sorted(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)
.thenComparing(File::getName))
.toList();
}
}
public static boolean isVisiblePackDirectory(File candidate) {
if (candidate == null || isHiddenName(candidate.getName())) {
return false;
}
Path path = candidate.toPath();
return Files.isDirectory(path);
}
public static void requireSafePackTree(File candidate) throws IOException {
if (!isVisiblePackDirectory(candidate)) {
throw new IOException("Pack directory is missing or unsafe: " + candidate);
}
Path root = candidate.toPath().toAbsolutePath().normalize().toRealPath();
if (Files.isSymbolicLink(root) || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Pack directory target is missing or unsafe: " + candidate);
}
Files.walkFileTree(root, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
if (!directory.equals(root) && isHiddenName(directory.getFileName().toString())) {
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
throw new IOException("Pack directory contains a symbolic link: " + file);
}
if (!attributes.isRegularFile()) {
throw new IOException("Pack directory contains an unsupported entry: " + file);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
throw new IOException("Unable to inspect pack entry: " + file, failure);
}
});
}
public static boolean containsHiddenPathSegment(Path root, Path candidate) {
if (root == null || candidate == null) {
return true;
}
Path normalizedRoot = root.toAbsolutePath().normalize();
Path normalizedCandidate = candidate.toAbsolutePath().normalize();
if (!normalizedCandidate.startsWith(normalizedRoot)) {
return true;
}
Path relative = normalizedRoot.relativize(normalizedCandidate);
for (Path segment : relative) {
if (isHiddenName(segment.toString())) {
return true;
}
}
return false;
}
public static boolean isHiddenName(String name) {
return name != null && name.startsWith(".");
}
} }
@@ -23,18 +23,33 @@ import art.arcane.iris.core.localization.PackDownloadMessages;
import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.util.common.misc.WebCache; import art.arcane.iris.util.common.misc.WebCache;
import art.arcane.volmlib.util.io.IO;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
import org.zeroturnaround.zip.ZipUtil;
import org.zeroturnaround.zip.commons.FileUtils; import org.zeroturnaround.zip.commons.FileUtils;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
public final class PackDownloader { public final class PackDownloader {
private static final String DEFAULT_OVERWORLD_PACK = "overworld"; private static final String DEFAULT_OVERWORLD_PACK = "overworld";
@@ -43,7 +58,14 @@ public final class PackDownloader {
private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+"); private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+");
private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*"); private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*");
private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}"); private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}");
private static final ConcurrentHashMap<String, Object> DOWNLOAD_LOCKS = new ConcurrentHashMap<>(); private static final Pattern PACK_KEY = Pattern.compile("[a-z0-9_-]+");
private static final ArchiveLimits ARCHIVE_LIMITS = new ArchiveLimits(
512L * 1024L * 1024L,
100_000,
2L * 1024L * 1024L * 1024L,
256L * 1024L * 1024L
);
private static final ConcurrentHashMap<String, DownloadLock> DOWNLOAD_LOCKS = new ConcurrentHashMap<>();
private PackDownloader() { private PackDownloader() {
} }
@@ -63,15 +85,37 @@ public final class PackDownloader {
* partial import (an interrupted copy) and counts as absent so it can be replaced. * partial import (an interrupted copy) and counts as absent so it can be replaced.
*/ */
public static boolean isPackPresent(File packsFolder, String key) { public static boolean isPackPresent(File packsFolder, String key) {
if (packsFolder == null || key == null || key.isBlank()) { if (packsFolder == null || !isSafePackKey(key)) {
return false;
}
Path packsRoot = packsFolder.toPath().toAbsolutePath().normalize();
File resolvedPack = PackDirectoryResolver.resolveExisting(packsFolder, key);
if (resolvedPack == null) {
return false;
}
try {
PackDirectoryResolver.requireSafePackTree(resolvedPack);
} catch (IOException exception) {
return false;
}
Path pack = resolvedPack.toPath().toAbsolutePath().normalize();
Path dimensions = pack.resolve("dimensions");
if (!Objects.equals(pack.getParent(), packsRoot)
|| Files.isSymbolicLink(dimensions)
|| !Files.isDirectory(pack)
|| !Files.isDirectory(dimensions, LinkOption.NOFOLLOW_LINKS)) {
return false;
}
try (Stream<Path> entries = Files.list(dimensions)) {
return entries.anyMatch(path -> path.getFileName().toString().endsWith(".json")
&& !Files.isSymbolicLink(path)
&& Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS));
} catch (IOException exception) {
return false; return false;
} }
File[] dimensions = new File(new File(packsFolder, key), "dimensions")
.listFiles((File dir, String name) -> name.endsWith(".json"));
return dimensions != null && dimensions.length > 0;
} }
public static String downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer<String> feedback) throws IOException { public static PackInstallResult downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer<String> feedback) throws IOException {
return download(packsFolder, DEFAULT_OVERWORLD_REPOSITORY, defaultOverworldReleaseUrl(), forceOverwrite, true, DEFAULT_OVERWORLD_PACK, feedback); return download(packsFolder, DEFAULT_OVERWORLD_REPOSITORY, defaultOverworldReleaseUrl(), forceOverwrite, true, DEFAULT_OVERWORLD_PACK, feedback);
} }
@@ -82,136 +126,414 @@ public final class PackDownloader {
* per-repo lock keeps concurrent startup triggers (async default-pack install racing world * per-repo lock keeps concurrent startup triggers (async default-pack install racing world
* resolution) from downloading the same archive twice. * resolution) from downloading the same archive twice.
*/ */
public static String download(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, String expectedKey, Consumer<String> feedback) throws IOException { public static PackInstallResult download(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, String expectedKey, Consumer<String> feedback) throws IOException {
// Lock on the destination pack key when known: concurrent triggers for the same pack can Objects.requireNonNull(packsFolder, "packsFolder");
// arrive with different refs (release URL vs listing branch) and must still serialize. Consumer<String> output = feedback == null ? ignored -> {
String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref; } : feedback;
Object lock = DOWNLOAD_LOCKS.computeIfAbsent(lockKey, key -> new Object()); if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) {
synchronized (lock) { throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'");
if (!forceOverwrite && isPackPresent(packsFolder, expectedKey)) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
return expectedKey;
}
return downloadLocked(packsFolder, repo, ref, forceOverwrite, directUrl, feedback);
} }
String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref;
return withDownloadLock(lockKey, () -> {
if (!forceOverwrite && isPackPresent(packsFolder, expectedKey)) {
sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
return new PackInstallResult(expectedKey, false, false);
}
return downloadLocked(packsFolder, repo, ref, forceOverwrite, directUrl, expectedKey, lockKey, output);
});
} }
private static String downloadLocked(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, Consumer<String> feedback) throws IOException { private static PackInstallResult downloadLocked(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl,
String expectedKey, String heldLockKey, Consumer<String> feedback) throws IOException {
String url = directUrl ? ref : resolveGithubArchiveUrl(repo, ref); String url = directUrl ? ref : resolveGithubArchiveUrl(repo, ref);
feedback.accept(IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " "); //The extra space stops a bug in adventure API from repeating the last letter of the URL sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " ");
File zip = WebCache.getNonCachedFile("pack-" + repo, url); File zip = WebCache.getNonCachedFile("pack-" + repo, url, ARCHIVE_LIMITS.maxArchiveBytes());
File temp = WebCache.getTemp(); File temp = WebCache.getTemp();
File work = new File(temp, "dl-" + UUID.randomUUID()); File work = new File(temp, "dl-" + UUID.randomUUID());
if (zip == null || !zip.exists()) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.FAILED_TO_FIND, MessageArgument.untrusted("url", url)));
feedback.accept(IrisLanguage.plain(PackDownloadMessages.CHECK_REPOSITORY_AND_BRANCH));
feedback.accept(IrisLanguage.plain(PackDownloadMessages.EXAMPLE_COMMAND));
return null;
}
feedback.accept(IrisLanguage.plain(PackDownloadMessages.UNPACKING, MessageArgument.untrusted("repository", repo)));
try { try {
ZipUtil.unpack(zip, work); if (zip == null || !zip.exists()) {
} catch (Throwable e) { sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.FAILED_TO_FIND, MessageArgument.untrusted("url", url)));
IrisLogging.reportError(e); sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_REPOSITORY_AND_BRANCH));
feedback.accept(IrisLanguage.plain(PackDownloadMessages.UNPACK_FAILED)); sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.EXAMPLE_COMMAND));
IO.delete(work);
return null;
}
File dir = null;
File[] zipFiles = work.listFiles();
if (zipFiles == null) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.NO_EXTRACTED_FILES));
return null;
}
try {
dir = zipFiles.length > 1 ? work : zipFiles[0].isDirectory() ? zipFiles[0] : null;
} catch (NullPointerException e) {
IrisLogging.reportError(e);
feedback.accept(IrisLanguage.plain(PackDownloadMessages.HOME_DIRECTORY_ERROR));
return null;
}
if (dir == null) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_ARCHIVE_FORMAT));
return null;
}
IrisData data = IrisData.get(dir);
String[] dimensions = data.getDimensionLoader().getPossibleKeys();
if (dimensions == null || dimensions.length == 0) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.NO_DIMENSION_FILE));
feedback.accept(IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB));
return null;
}
if (dimensions.length != 1) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
return null;
}
IrisDimension d = data.getDimensionLoader().load(dimensions[0]);
data.close();
if (d == null) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION));
return null;
}
String key = d.getLoadKey();
feedback.accept(IrisLanguage.plain(PackDownloadMessages.IMPORTING, MessageArgument.untrusted("name", d.getName()), MessageArgument.untrusted("key", key)));
File packEntry = new File(packsFolder, key);
File[] staleStaging = packsFolder.listFiles((File parent, String name) -> name.startsWith(key + ".importing-"));
if (staleStaging != null) {
for (File stale : staleStaging) {
IO.delete(stale);
}
}
if (forceOverwrite) {
IO.delete(packEntry);
}
if (IrisData.loadAnyDimension(key, null) != null) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.DIMENSION_KEY_CONFLICT, MessageArgument.untrusted("key", key)));
return null;
}
File[] existingEntries = packEntry.listFiles();
if (packEntry.exists() && existingEntries != null && existingEntries.length > 0) {
if (isPackPresent(packsFolder, key)) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.PACK_KEY_CONFLICT, MessageArgument.untrusted("key", key)));
return null; return null;
} }
// Non-empty but no dimension file: a partial import from an interrupted copy. sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACKING, MessageArgument.untrusted("repository", repo)));
// Replace it instead of refusing forever. try {
IrisLogging.warn("Replacing partial pack folder " + packEntry.getPath() + " (no dimension files found)."); unpackArchive(zip.toPath(), work.toPath(), ARCHIVE_LIMITS);
IO.delete(packEntry); } catch (IOException exception) {
} IrisLogging.reportError(exception);
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACK_FAILED));
// Stage inside the packs folder and move into place so packs/<key> is never partial: return null;
// an interrupted copy previously left a folder without dimensions/, which then blocked
// every future import as a key conflict.
File staging = new File(packsFolder, key + ".importing-" + UUID.randomUUID());
try {
FileUtils.copyDirectory(dir, staging);
if (!staging.renameTo(packEntry)) {
throw new IOException("Unable to move imported pack into place: " + packEntry.getPath());
} }
} catch (IOException | RuntimeException e) { File[] zipFiles = work.listFiles();
IO.delete(staging); if (zipFiles == null) {
throw e; sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.NO_EXTRACTED_FILES));
return null;
}
File directory;
try {
directory = zipFiles.length > 1 ? work : zipFiles[0].isDirectory() ? zipFiles[0] : null;
} catch (NullPointerException exception) {
IrisLogging.reportError(exception);
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.HOME_DIRECTORY_ERROR));
return null;
}
if (directory == null) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_ARCHIVE_FORMAT));
return null;
}
return installExtractedPack(packsFolder, directory, forceOverwrite, expectedKey, heldLockKey, feedback);
} finally {
deleteDirectory(work);
}
}
static PackInstallResult installExtractedPack(File packsFolder, File extractedPack, boolean forceOverwrite,
String expectedKey, Consumer<String> feedback) throws IOException {
Objects.requireNonNull(packsFolder, "packsFolder");
Objects.requireNonNull(extractedPack, "extractedPack");
Consumer<String> output = feedback == null ? ignored -> {
} : feedback;
return installExtractedPack(packsFolder, extractedPack, forceOverwrite, expectedKey, null, output);
}
private static PackInstallResult installExtractedPack(File packsFolder, File extractedPack, boolean forceOverwrite,
String expectedKey, String heldLockKey, Consumer<String> feedback) throws IOException {
if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) {
throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'");
}
Path packsRoot = packsFolder.toPath().toAbsolutePath().normalize();
Files.createDirectories(packsRoot);
Path staging = packsRoot.resolve(".iris-import-" + UUID.randomUUID());
try {
FileUtils.copyDirectory(extractedPack, staging.toFile());
PreparedPack prepared = prepareStagedPack(staging.toFile(), expectedKey, feedback);
if (prepared == null) {
return null;
}
String destinationLockKey = "key:" + prepared.key();
if (destinationLockKey.equals(heldLockKey)) {
return publishPreparedPack(packsFolder, packsRoot, staging, prepared, forceOverwrite, feedback);
}
return withDownloadLock(destinationLockKey,
() -> publishPreparedPack(packsFolder, packsRoot, staging, prepared, forceOverwrite, feedback));
} finally {
deleteDirectory(staging.toFile());
}
}
private static PreparedPack prepareStagedPack(File staging, String expectedKey,
Consumer<String> feedback) throws IOException {
IrisData data = IrisData.openDatapackCompiler(staging);
String key;
String name;
try {
String[] dimensions = data.getDimensionLoader().getPossibleKeys();
if (dimensions == null || dimensions.length == 0) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.NO_DIMENSION_FILE));
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB));
return null;
}
if (dimensions.length != 1) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
return null;
}
IrisDimension dimension = data.getDimensionLoader().load(dimensions[0]);
if (dimension == null) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION));
return null;
}
key = dimension.getLoadKey();
name = dimension.getName();
} finally {
data.close();
}
if (!isSafePackKey(key)) {
throw new IOException("Downloaded pack has unsafe dimension key '" + key + "'");
}
if (expectedKey != null && !expectedKey.isBlank() && !expectedKey.equals(key)) {
throw new IOException("Downloaded pack key '" + key + "' does not match requested key '" + expectedKey + "'");
} }
IrisData.getLoaded(packEntry) PackValidationResult stagedValidation;
.ifPresent(IrisData::hotloaded); try {
stagedValidation = PackValidator.validate(staging);
} catch (RuntimeException exception) {
throw new IOException("Pack validation failed before publication for '" + key + "'", exception);
}
PackValidationResult validation = new PackValidationResult(
key,
stagedValidation.getBlockingErrors(),
stagedValidation.getWarnings(),
stagedValidation.getValidatedAtMillis()
);
if (!validation.isLoadable()) {
sendValidationFeedback(validation, feedback);
return null;
}
sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.IMPORTING,
MessageArgument.untrusted("name", name),
MessageArgument.untrusted("key", key)
));
return new PreparedPack(key, name, validation);
}
feedback.accept(IrisLanguage.plain(PackDownloadMessages.ACQUIRED, MessageArgument.untrusted("name", d.getName()))); private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared,
validateDownloaded(packEntry, feedback); boolean forceOverwrite, Consumer<String> feedback) throws IOException {
return key; Path target = packsRoot.resolve(prepared.key()).normalize();
if (!Objects.equals(target.getParent(), packsRoot)) {
throw new IOException("Pack target escapes the packs folder: " + target);
}
if (Files.isSymbolicLink(target)) {
sendFeedback(feedback, "Pack '" + prepared.key() + "' is a symbolic-link source and cannot be replaced by Iris.");
return null;
}
Path conflictingPack = findConflictingPack(packsRoot, staging, target, prepared.key());
if (conflictingPack != null) {
sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.DIMENSION_KEY_CONFLICT,
MessageArgument.untrusted("key", prepared.key())
));
return null;
}
if (!forceOverwrite && isPackPresent(packsRoot.toFile(), prepared.key())) {
sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.PACK_KEY_CONFLICT,
MessageArgument.untrusted("key", prepared.key())
));
return null;
}
if (!forceOverwrite && Files.exists(target) && !isPackPresent(packsRoot.toFile(), prepared.key())) {
IrisLogging.warn("Replacing partial pack folder " + target + " (no dimension files found).");
}
Optional<IrisData> loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key()));
if (loadedData.isEmpty()) {
loadedData = IrisData.getLoaded(target.toFile());
}
if (loadedData.isPresent()) {
sendFeedback(
feedback,
"Pack '" + prepared.key() + "' is active and cannot be replaced safely. Unload its worlds before retrying."
);
return null;
}
try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) {
publication.commit();
try {
publication.cleanupBackup();
} catch (IOException exception) {
IrisLogging.reportError(
"Pack '" + prepared.key() + "' was published, but its transaction backup could not be cleaned.",
exception
);
}
}
PackValidationRegistry.publish(prepared.validation());
sendValidationFeedback(prepared.validation(), feedback);
sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.ACQUIRED,
MessageArgument.untrusted("name", prepared.name())
));
return new PackInstallResult(prepared.key(), true, false);
}
private static Path findConflictingPack(Path packsRoot, Path staging, Path target, String key) throws IOException {
Set<Path> roots = new LinkedHashSet<>();
roots.add(packsRoot);
if (IrisPlatforms.isBound()) {
roots.add(IrisPlatforms.get().dataFolder("packs").toPath().toAbsolutePath().normalize());
}
for (Path root : roots) {
if (!Files.isDirectory(root)) {
continue;
}
try (Stream<Path> entries = Files.list(root)) {
List<Path> candidates = entries.toList();
for (Path candidate : candidates) {
Path normalized = candidate.toAbsolutePath().normalize();
String candidateName = candidate.getFileName().toString();
if (normalized.equals(staging)
|| normalized.equals(target)
|| PackDirectoryResolver.isHiddenName(candidateName)
|| !PackDirectoryResolver.isVisiblePackDirectory(candidate.toFile())) {
continue;
}
Path dimension = candidate.resolve("dimensions").resolve(key + ".json");
if (Files.isRegularFile(dimension)) {
return normalized;
}
}
}
}
return null;
}
private static boolean isSafePackKey(String key) {
return key != null && PACK_KEY.matcher(key).matches();
}
private static PackInstallResult withDownloadLock(String key, DownloadOperation operation) throws IOException {
DownloadLock lock = DOWNLOAD_LOCKS.compute(key, (ignored, existing) -> {
DownloadLock selected = existing == null ? new DownloadLock() : existing;
selected.references++;
return selected;
});
try {
synchronized (lock) {
return operation.run();
}
} finally {
DOWNLOAD_LOCKS.computeIfPresent(key, (ignored, existing) -> {
if (existing != lock) {
return existing;
}
existing.references--;
return existing.references == 0 ? null : existing;
});
}
}
static int downloadLockCount() {
return DOWNLOAD_LOCKS.size();
}
static void unpackArchive(Path archive, Path destination, ArchiveLimits limits) throws IOException {
Path source = Objects.requireNonNull(archive, "archive").toAbsolutePath().normalize();
Path root = Objects.requireNonNull(destination, "destination").toAbsolutePath().normalize();
ArchiveLimits safety = Objects.requireNonNull(limits, "limits");
if (Files.isSymbolicLink(source) || !Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Pack archive is missing or unsafe: " + source);
}
if (Files.size(source) > safety.maxArchiveBytes()) {
throw new IOException("Pack archive exceeds the compressed size limit.");
}
if (Files.exists(root, LinkOption.NOFOLLOW_LINKS) && !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Pack extraction target is not a directory: " + root);
}
Files.createDirectories(root);
if (Files.isSymbolicLink(root)) {
throw new IOException("Pack extraction target is unsafe: " + root);
}
int entryCount = 0;
long expandedBytes = 0L;
Set<String> paths = new HashSet<>();
try (InputStream input = Files.newInputStream(source); ZipInputStream zip = new ZipInputStream(input)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
entryCount++;
if (entryCount > safety.maxEntries()) {
throw new IOException("Pack archive contains too many entries.");
}
String normalizedName = normalizeArchiveEntry(entry.getName());
String collisionKey = normalizedName.toLowerCase(Locale.ROOT);
if (!paths.add(collisionKey)) {
throw new IOException("Pack archive contains a duplicate path: " + normalizedName);
}
Path output = root.resolve(normalizedName).normalize();
if (!output.startsWith(root)) {
throw new IOException("Pack archive entry escapes extraction: " + entry.getName());
}
if (entry.isDirectory()) {
Files.createDirectories(output);
zip.closeEntry();
continue;
}
long declaredSize = entry.getSize();
if (declaredSize > safety.maxEntryBytes()) {
throw new IOException("Pack archive entry exceeds the file size limit: " + normalizedName);
}
Files.createDirectories(output.getParent());
long entryBytes = 0L;
try (OutputStream file = Files.newOutputStream(output, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
byte[] buffer = new byte[8192];
int read;
while ((read = zip.read(buffer)) != -1) {
if (read == 0) {
continue;
}
entryBytes += read;
expandedBytes += read;
if (entryBytes > safety.maxEntryBytes()) {
throw new IOException("Pack archive entry exceeds the file size limit: " + normalizedName);
}
if (expandedBytes > safety.maxExpandedBytes()) {
throw new IOException("Pack archive expands beyond the safety limit.");
}
file.write(buffer, 0, read);
}
}
zip.closeEntry();
}
}
if (entryCount == 0) {
throw new IOException("Pack archive is empty.");
}
}
private static String normalizeArchiveEntry(String rawName) throws IOException {
if (rawName == null || rawName.isBlank() || rawName.indexOf('\0') >= 0
|| rawName.startsWith("/") || rawName.startsWith("\\")) {
throw new IOException("Pack archive contains an invalid path.");
}
String slashNormalized = rawName.replace('\\', '/');
if (slashNormalized.matches("^[A-Za-z]:.*")) {
throw new IOException("Pack archive contains an unsafe path: " + rawName);
}
Path normalized = Path.of(slashNormalized).normalize();
String result = normalized.toString().replace('\\', '/');
if (normalized.isAbsolute() || normalized.startsWith("..") || result.isBlank() || ".".equals(result)) {
throw new IOException("Pack archive contains an unsafe path: " + rawName);
}
return result;
}
private static void deleteDirectory(File directory) {
try {
AtomicDirectoryPublisher.deleteTree(directory.toPath());
} catch (IOException exception) {
IrisLogging.reportError("Failed to clean temporary pack directory '" + directory.getPath() + "'", exception);
}
}
private static void sendFeedback(Consumer<String> feedback, String message) {
try {
feedback.accept(message);
} catch (RuntimeException exception) {
IrisLogging.reportError("Pack download feedback delivery failed", exception);
}
}
private static void sendValidationFeedback(PackValidationResult result, Consumer<String> feedback) {
if (!result.isLoadable()) {
sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.VALIDATION_FAILED,
MessageArgument.untrusted("pack", result.getPackName())
));
for (String reason : result.getBlockingErrors()) {
sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.VALIDATION_REASON,
MessageArgument.untrusted("reason", reason)
));
}
return;
}
if (!result.getWarnings().isEmpty()) {
sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.VALIDATED_WITH_WARNINGS,
MessageArgument.untrusted("pack", result.getPackName()),
MessageArgument.trusted("count", result.getWarnings().size())
));
return;
}
sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.VALIDATED,
MessageArgument.untrusted("pack", result.getPackName())
));
} }
static String resolveGithubArchiveUrl(String repo, String ref) { static String resolveGithubArchiveUrl(String repo, String ref) {
@@ -229,6 +551,9 @@ public final class PackDownloader {
if (COMMIT_SHA.matcher(ref).matches()) { if (COMMIT_SHA.matcher(ref).matches()) {
return "https://github.com/" + repo + "/archive/" + ref + ".zip"; return "https://github.com/" + repo + "/archive/" + ref + ".zip";
} }
if ("HEAD".equals(ref)) {
return "https://github.com/" + repo + "/archive/HEAD.zip";
}
if (ref.startsWith("refs/") && !ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) { if (ref.startsWith("refs/") && !ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) {
throw new IllegalArgumentException("Unsupported GitHub reference '" + ref + "'"); throw new IllegalArgumentException("Unsupported GitHub reference '" + ref + "'");
} }
@@ -269,27 +594,26 @@ public final class PackDownloader {
} }
} }
private static void validateDownloaded(File packEntry, Consumer<String> feedback) { private record PreparedPack(String key, String name, PackValidationResult validation) {
try { }
PackValidationResult result = PackValidator.validate(packEntry);
PackValidationRegistry.publish(result);
if (!result.isLoadable()) { public record PackInstallResult(String key, boolean changed, boolean restartRequired) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.VALIDATION_FAILED, MessageArgument.untrusted("pack", result.getPackName()))); }
for (String reason : result.getBlockingErrors()) {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.VALIDATION_REASON, MessageArgument.untrusted("reason", reason))); record ArchiveLimits(long maxArchiveBytes, int maxEntries, long maxExpandedBytes, long maxEntryBytes) {
} ArchiveLimits {
} else if (!result.getWarnings().isEmpty()) { if (maxArchiveBytes < 1L || maxEntries < 1 || maxExpandedBytes < 1L || maxEntryBytes < 1L) {
feedback.accept(IrisLanguage.plain( throw new IllegalArgumentException("Archive limits must be positive.");
PackDownloadMessages.VALIDATED_WITH_WARNINGS,
MessageArgument.untrusted("pack", result.getPackName()),
MessageArgument.trusted("count", result.getWarnings().size())
));
} else {
feedback.accept(IrisLanguage.plain(PackDownloadMessages.VALIDATED, MessageArgument.untrusted("pack", result.getPackName())));
} }
} catch (Throwable e) {
IrisLogging.reportError("Pack validation failed for '" + packEntry.getName() + "'", e);
} }
} }
@FunctionalInterface
private interface DownloadOperation {
PackInstallResult run() throws IOException;
}
private static final class DownloadLock {
private int references;
}
} }
@@ -117,6 +117,10 @@ final class PackObjectSurfaceValidator {
} }
static List<String> validateStructureGraph(File packFolder) { static List<String> validateStructureGraph(File packFolder) {
return validateStructureGraph(packFolder, true);
}
static List<String> validateStructureGraph(File packFolder, boolean validateLiveRegistries) {
List<String> blockingErrors = new ArrayList<>(); List<String> blockingErrors = new ArrayList<>();
if (packFolder == null || !packFolder.isDirectory()) { if (packFolder == null || !packFolder.isDirectory()) {
return blockingErrors; return blockingErrors;
@@ -131,7 +135,8 @@ final class PackObjectSurfaceValidator {
Set<String> pieceKeys = ContentKeyValidator.deriveRegistrantKeysExact(piecesFolder); Set<String> pieceKeys = ContentKeyValidator.deriveRegistrantKeysExact(piecesFolder);
Set<String> objectKeys = ContentKeyValidator.deriveObjectKeysExact(objectsFolder); Set<String> objectKeys = ContentKeyValidator.deriveObjectKeysExact(objectsFolder);
PackStructurePlacementValidator.validateStructurePlacements(packFolder, structureKeys, blockingErrors); PackStructurePlacementValidator.validateStructurePlacements(
packFolder, structureKeys, validateLiveRegistries, blockingErrors);
PackStructurePlacementValidator.validateStructureStartPools(structuresFolder, poolKeys, blockingErrors); PackStructurePlacementValidator.validateStructureStartPools(structuresFolder, poolKeys, blockingErrors);
PackStructurePlacementValidator.validateJigsawPools(poolsFolder, poolKeys, pieceKeys, blockingErrors); PackStructurePlacementValidator.validateJigsawPools(poolsFolder, poolKeys, pieceKeys, blockingErrors);
PackStructurePlacementValidator.validateJigsawPieces(piecesFolder, poolKeys, objectKeys, blockingErrors); PackStructurePlacementValidator.validateJigsawPieces(piecesFolder, poolKeys, objectKeys, blockingErrors);
@@ -18,7 +18,10 @@
package art.arcane.iris.core.pack; package art.arcane.iris.core.pack;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformStructureHooks;
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject; import art.arcane.volmlib.util.json.JSONObject;
@@ -26,22 +29,32 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Set; import java.util.Set;
final class PackStructurePlacementValidator { final class PackStructurePlacementValidator {
private static final Set<String> TERRAIN_ENVELOPE_MODES = Set.of(
"BORE", "FORCE_CARVE", "VACUUM", "ENCASE");
private PackStructurePlacementValidator() { private PackStructurePlacementValidator() {
} }
static void validateStructurePlacements(File packFolder, static void validateStructurePlacements(File packFolder,
Set<String> structureKeys, Set<String> structureKeys,
boolean validateLiveRegistries,
List<String> blockingErrors) { List<String> blockingErrors) {
Set<String> registeredStructures = registeredStructureKeys(); RegistrySnapshot registries = registrySnapshot(validateLiveRegistries, blockingErrors);
Set<String> registeredJigsaws = registeredJigsawKeys(); if (registries == null) {
Set<String> registeredPools = registeredTemplatePoolKeys(); return;
}
Map<String, String> placementIds = new HashMap<>();
Map<String, String> anonymousGridIdentities = new HashMap<>();
for (String folderName : PackValidator.STRUCTURE_HOST_FOLDERS) { for (String folderName : PackValidator.STRUCTURE_HOST_FOLDERS) {
File resourceFolder = new File(packFolder, folderName); File resourceFolder = new File(packFolder, folderName);
if (!resourceFolder.isDirectory()) { if (!resourceFolder.isDirectory()) {
@@ -55,22 +68,42 @@ final class PackStructurePlacementValidator {
if (resource == null) { if (resource == null) {
continue; continue;
} }
String resourceKey = PackValidationIo.deriveKey(resourceFolder, resourceFile);
if (PackValidator.DIMENSIONS_FOLDER.equals(folderName)) {
validateImportedStructureAdjustmentEnvelopes(
resourceKey, resource, registries, blockingErrors);
}
Object rawPlacements = resource.opt("structures");
JSONArray placements = resource.optJSONArray("structures"); JSONArray placements = resource.optJSONArray("structures");
if (placements == null) { if (placements == null) {
if (resource.has("structures") && rawPlacements != JSONObject.NULL) {
blockingErrors.add(resourceType + " '" + resourceKey + "'.structures must be an array.");
}
continue; continue;
} }
String resourceKey = PackValidationIo.deriveKey(resourceFolder, resourceFile);
for (int placementIndex = 0; placementIndex < placements.length(); placementIndex++) { for (int placementIndex = 0; placementIndex < placements.length(); placementIndex++) {
JSONObject placement = placements.optJSONObject(placementIndex); JSONObject placement = placements.optJSONObject(placementIndex);
if (placement == null) {
continue;
}
JSONArray references = placement.optJSONArray("structures");
JSONArray nativeStructures = placement.optJSONArray("nativeStructures");
boolean hasIrisStructures = references != null && references.length() > 0;
boolean hasNativeStructures = nativeStructures != null && nativeStructures.length() > 0;
String placementPath = resourceType + " '" + resourceKey + "' structures[" String placementPath = resourceType + " '" + resourceKey + "' structures["
+ placementIndex + "]"; + placementIndex + "]";
if (placement == null) {
blockingErrors.add(placementPath + " must be an object.");
continue;
}
validatePlacementConfiguration(placementPath, placement, blockingErrors);
validatePlacementIdentity(
placementPath, placement, placementIds, anonymousGridIdentities, blockingErrors);
JSONArray references = placement.optJSONArray("structures");
JSONArray nativeStructures = placement.optJSONArray("nativeStructures");
if (placement.has("structures") && placement.opt("structures") != JSONObject.NULL
&& references == null) {
blockingErrors.add(placementPath + ".structures must be an array.");
}
if (placement.has("nativeStructures") && placement.opt("nativeStructures") != JSONObject.NULL
&& nativeStructures == null) {
blockingErrors.add(placementPath + ".nativeStructures must be an array.");
}
boolean hasIrisStructures = references != null && references.length() > 0;
boolean hasNativeStructures = nativeStructures != null && nativeStructures.length() > 0;
if (hasIrisStructures == hasNativeStructures) { if (hasIrisStructures == hasNativeStructures) {
blockingErrors.add(placementPath blockingErrors.add(placementPath
+ " must declare exactly one non-empty backend: structures or nativeStructures."); + " must declare exactly one non-empty backend: structures or nativeStructures.");
@@ -78,14 +111,24 @@ final class PackStructurePlacementValidator {
} }
if (hasNativeStructures) { if (hasNativeStructures) {
validateNativeStructures( validateNativeStructures(
placementPath, placement, nativeStructures, placementPath, nativeStructures,
registeredStructures, registeredJigsaws, registries.structures(), registries.jigsaws(),
registeredPools, blockingErrors); registries.pools(), registries.jigsawMetadataResolver(),
registries.hooks(), blockingErrors);
continue; continue;
} }
Set<String> editableStructureKeys = new HashSet<>();
for (int referenceIndex = 0; referenceIndex < references.length(); referenceIndex++) { for (int referenceIndex = 0; referenceIndex < references.length(); referenceIndex++) {
Object rawReference = references.opt(referenceIndex); Object rawReference = references.opt(referenceIndex);
if (!(rawReference instanceof String structureKey) || structureKey.isBlank()) { if (!(rawReference instanceof String structureKey) || structureKey.isBlank()) {
blockingErrors.add(placementPath + ".structures[" + referenceIndex
+ "] must name a non-blank Iris structure.");
continue;
}
String normalizedStructureKey = structureKey.trim().toLowerCase(Locale.ROOT);
if (!editableStructureKeys.add(normalizedStructureKey)) {
blockingErrors.add(placementPath + ".structures[" + referenceIndex
+ "] duplicates Iris structure '" + structureKey + "'.");
continue; continue;
} }
if (!structureKeys.contains(structureKey)) { if (!structureKeys.contains(structureKey)) {
@@ -99,66 +142,240 @@ final class PackStructurePlacementValidator {
} }
} }
private static Set<String> registeredJigsawKeys() { private static RegistrySnapshot registrySnapshot(boolean validateLiveRegistries,
List<String> blockingErrors) {
if (!validateLiveRegistries || !IrisPlatforms.isBound()) {
return new RegistrySnapshot(Set.of(), Set.of(), Set.of(), null, null);
}
try { try {
List<String> registered = IrisPlatforms.get().structureHooks().jigsawStructureKeys(); PlatformStructureHooks hooks = IrisPlatforms.get().structureHooks();
if (registered == null || registered.isEmpty()) { if (hooks == null) {
return Set.of(); throw new IllegalStateException("The active platform did not provide structure registry hooks");
} }
Set<String> keys = new HashSet<>(); Set<String> structures = normalizeRegistryKeys(
for (String key : registered) { hooks.structureKeys(), "structure");
if (key != null && !key.isBlank()) { Set<String> jigsaws = normalizeRegistryKeys(
keys.add(key.toLowerCase(Locale.ROOT)); hooks.jigsawStructureKeys(), "jigsaw structure");
} Set<String> pools = normalizeRegistryKeys(
hooks.templatePoolKeys(), "template pool");
if (structures.isEmpty()) {
throw new IllegalStateException("The active structure registry is empty");
} }
return Set.copyOf(keys); if (jigsaws.isEmpty()) {
} catch (Throwable ignored) { throw new IllegalStateException("The active jigsaw structure registry is empty");
return Set.of(); }
if (pools.isEmpty()) {
throw new IllegalStateException("The active template pool registry is empty");
}
return new RegistrySnapshot(
structures, jigsaws, pools, new JigsawMetadataResolver(hooks), hooks);
} catch (RuntimeException | LinkageError e) {
IrisLogging.reportError("Could not read the live structure registries during pack validation", e);
blockingErrors.add("Could not validate native structure references against the live registries: "
+ failureMessage(e) + ".");
return null;
} }
} }
private static Set<String> registeredStructureKeys() { private static Set<String> normalizeRegistryKeys(List<String> registered, String registryName) {
try { if (registered == null) {
List<String> registered = IrisPlatforms.get().structureHooks().structureKeys(); throw new IllegalStateException("The active " + registryName + " registry returned null");
if (registered == null || registered.isEmpty()) { }
return Set.of(); Set<String> keys = new HashSet<>();
for (String key : registered) {
if (key != null && !key.isBlank()) {
keys.add(key.toLowerCase(Locale.ROOT));
} }
Set<String> keys = new HashSet<>(); }
for (String key : registered) { return Set.copyOf(keys);
if (key != null && !key.isBlank()) { }
keys.add(key.toLowerCase(Locale.ROOT));
} private static String failureMessage(Throwable throwable) {
String message = throwable.getMessage();
return message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message;
}
private static void validatePlacementConfiguration(String path, JSONObject placement,
List<String> blockingErrors) {
PackJsonFieldChecks.validateOptionalEnum(path, placement, "distribution",
Set.of("RANDOM_SPREAD", "DENSITY", "CONCENTRIC_RINGS"), blockingErrors);
PackJsonFieldChecks.validateOptionalIntegerRange(path, placement, "spacing", 1, 4096, blockingErrors);
PackJsonFieldChecks.validateOptionalIntegerRange(
path, placement, "separation", 0, Integer.MAX_VALUE, blockingErrors);
PackJsonFieldChecks.validateOptionalIntegerRange(
path, placement, "salt", Integer.MIN_VALUE, Integer.MAX_VALUE, blockingErrors);
PackJsonFieldChecks.validateOptionalDoubleRange(path, placement, "density", 0D, 1D, blockingErrors);
PackJsonFieldChecks.validateOptionalIntegerRange(
path, placement, "ringCount", 1, Integer.MAX_VALUE, blockingErrors);
PackJsonFieldChecks.validateOptionalIntegerRange(
path, placement, "ringDistance", 1, Integer.MAX_VALUE, blockingErrors);
PackJsonFieldChecks.validateOptionalIntegerRange(
path, placement, "ringSpread", 1, Integer.MAX_VALUE, blockingErrors);
PackJsonFieldChecks.validateOptionalIntegerRange(
path, placement, "minHeight", Integer.MIN_VALUE, Integer.MAX_VALUE, blockingErrors);
PackJsonFieldChecks.validateOptionalIntegerRange(
path, placement, "maxHeight", Integer.MIN_VALUE, Integer.MAX_VALUE, blockingErrors);
PackJsonFieldChecks.validateOptionalBoolean(path, placement, "underground", blockingErrors);
PackJsonFieldChecks.validateOptionalBoolean(path, placement, "underwater", blockingErrors);
PackJsonFieldChecks.validateOptionalEnum(path, placement, "nativeSuppression",
Set.of("NONE", "REPLACE_SOURCE"), blockingErrors);
Integer spacing = integerValue(placement, "spacing", 32);
Integer separation = integerValue(placement, "separation", 8);
if (spacing != null && separation != null && separation >= spacing) {
blockingErrors.add(path + ".separation must be smaller than spacing.");
}
Integer minimumHeight = integerValue(placement, "minHeight", -2032);
Integer maximumHeight = integerValue(placement, "maxHeight", 2032);
if (minimumHeight != null && maximumHeight != null && minimumHeight > maximumHeight) {
blockingErrors.add(path + " has an inverted height band: minHeight must not exceed maxHeight.");
}
Integer ringCount = integerValue(placement, "ringCount", 128);
Integer ringDistance = integerValue(placement, "ringDistance", 32);
Integer ringSpread = integerValue(placement, "ringSpread", 3);
if (ringCount != null && ringDistance != null && ringSpread != null
&& ringCount > 0 && ringDistance > 0 && ringSpread > 0) {
long ringRadius = (long) Math.ceilDiv(ringCount, ringSpread) * ringDistance;
if (ringRadius > Integer.MAX_VALUE) {
blockingErrors.add(path + " concentric ring radius exceeds the supported chunk coordinate range.");
} }
return Set.copyOf(keys); }
} catch (Throwable ignored) {
return Set.of(); if (placement.has("placementId")) {
Object rawPlacementId = placement.opt("placementId");
if (!(rawPlacementId instanceof String placementId)) {
blockingErrors.add(path + ".placementId must be a string.");
} else if (!placementId.isEmpty() && placementId.isBlank()) {
blockingErrors.add(path + ".placementId must be omitted or non-blank.");
} else if (!placementId.equals(placementId.trim())) {
blockingErrors.add(path + ".placementId must not contain leading or trailing whitespace.");
}
}
validateStilt(path, placement, blockingErrors);
validateNativeTerrain(path, placement, blockingErrors);
}
private static void validatePlacementIdentity(String path, JSONObject placement,
Map<String, String> placementIds,
Map<String, String> anonymousGridIdentities,
List<String> blockingErrors) {
Object rawPlacementId = placement.opt("placementId");
if (rawPlacementId instanceof String placementId && !placementId.isBlank()) {
String normalizedId = placementId.trim();
String existing = placementIds.putIfAbsent(normalizedId, path);
if (existing != null) {
blockingErrors.add(path + ".placementId duplicates '" + normalizedId
+ "' already declared by " + existing + ".");
}
return;
}
String gridIdentity = anonymousGridIdentity(placement);
String existing = anonymousGridIdentities.putIfAbsent(gridIdentity, path);
if (existing != null) {
blockingErrors.add(path + " duplicates an anonymous placement grid already declared by "
+ existing + "; give distinct placements unique placementId values.");
} }
} }
private static Set<String> registeredTemplatePoolKeys() { private static String anonymousGridIdentity(JSONObject placement) {
try { StringBuilder identity = new StringBuilder();
List<String> registered = IrisPlatforms.get().structureHooks().templatePoolKeys(); appendIdentity(identity, placement.optString("distribution", "RANDOM_SPREAD"));
if (registered == null || registered.isEmpty()) { appendIdentity(identity, integerValue(placement, "salt", 165745296));
return Set.of(); appendIdentity(identity, integerValue(placement, "spacing", 32));
appendIdentity(identity, integerValue(placement, "separation", 8));
Object rawDensity = placement.has("density") ? placement.opt("density") : 0.02D;
appendIdentity(identity, rawDensity instanceof Number number
? Double.doubleToLongBits(number.doubleValue()) : rawDensity);
appendIdentity(identity, integerValue(placement, "ringCount", 128));
appendIdentity(identity, integerValue(placement, "ringDistance", 32));
appendIdentity(identity, integerValue(placement, "ringSpread", 3));
appendIdentity(identity, integerValue(placement, "minHeight", -2032));
appendIdentity(identity, integerValue(placement, "maxHeight", 2032));
appendIdentity(identity, placement.opt("underground") instanceof Boolean underground && underground);
appendIdentity(identity, placement.opt("underwater") instanceof Boolean underwater && underwater);
JSONArray structures = placement.optJSONArray("structures");
JSONArray nativeStructures = placement.optJSONArray("nativeStructures");
if (structures != null && structures.length() > 0) {
appendIdentity(identity, "iris");
List<String> orderedStructures = new ArrayList<>(structures.length());
for (int index = 0; index < structures.length(); index++) {
orderedStructures.add(String.valueOf(structures.opt(index)));
} }
Set<String> keys = new HashSet<>(); orderedStructures.sort(String::compareTo);
for (String key : registered) { for (String structure : orderedStructures) {
if (key != null && !key.isBlank()) { appendIdentity(identity, structure);
keys.add(key.toLowerCase(Locale.ROOT)); }
} else {
appendIdentity(identity, "native");
if (nativeStructures != null) {
List<String> orderedSources = new ArrayList<>(nativeStructures.length());
for (int index = 0; index < nativeStructures.length(); index++) {
JSONObject source = nativeStructures.optJSONObject(index);
String sourceKey = source == null ? "null" : source.optString("structure", "");
Integer weight = source == null ? null : integerValue(source, "weight", 1);
orderedSources.add(sourceKey.length() + ":" + sourceKey + ":" + weight);
}
orderedSources.sort(String::compareTo);
for (String source : orderedSources) {
appendIdentity(identity, source);
} }
} }
return Set.copyOf(keys); }
} catch (Throwable ignored) { return identity.toString();
return Set.of(); }
private static void appendIdentity(StringBuilder identity, Object value) {
String text = String.valueOf(value);
identity.append(text.length()).append(':').append(text).append('|');
}
private static void validateStilt(String path, JSONObject placement, List<String> blockingErrors) {
if (!placement.has("stilt") || placement.opt("stilt") == JSONObject.NULL) {
return;
}
JSONObject stilt = placement.optJSONObject("stilt");
if (stilt == null) {
blockingErrors.add(path + ".stilt must be an object.");
return;
}
PackJsonFieldChecks.validateOptionalIntegerRange(
path + ".stilt", stilt, "maxDepth", 1, 4064, blockingErrors);
PackJsonFieldChecks.validateOptionalIntegerRange(
path + ".stilt", stilt, "spacing", 1, 64, blockingErrors);
PackJsonFieldChecks.validateOptionalBoolean(
path + ".stilt", stilt, "supportNonOccluding", blockingErrors);
if (stilt.has("palette") && stilt.opt("palette") != JSONObject.NULL
&& stilt.optJSONObject("palette") == null) {
blockingErrors.add(path + ".stilt.palette must be an object.");
} }
} }
private static void validateNativeStructures(String placementPath, JSONObject placement, private static Integer integerValue(JSONObject object, String field, int defaultValue) {
if (!object.has(field)) {
return defaultValue;
}
Object rawValue = object.opt(field);
if (!(rawValue instanceof Number number)) {
return null;
}
double doubleValue = number.doubleValue();
if (!Double.isFinite(doubleValue) || Math.rint(doubleValue) != doubleValue
|| doubleValue < Integer.MIN_VALUE || doubleValue > Integer.MAX_VALUE) {
return null;
}
return number.intValue();
}
private static void validateNativeStructures(String placementPath,
JSONArray nativeStructures, JSONArray nativeStructures,
Set<String> registeredStructures, Set<String> registeredStructures,
Set<String> registeredJigsaws, Set<String> registeredJigsaws,
Set<String> registeredPools, Set<String> registeredPools,
JigsawMetadataResolver jigsawMetadataResolver,
PlatformStructureHooks hooks,
List<String> blockingErrors) { List<String> blockingErrors) {
Set<String> sourceKeys = new HashSet<>();
long totalWeight = 0L;
for (int sourceIndex = 0; sourceIndex < nativeStructures.length(); sourceIndex++) { for (int sourceIndex = 0; sourceIndex < nativeStructures.length(); sourceIndex++) {
String sourcePath = placementPath + ".nativeStructures[" + sourceIndex + "]"; String sourcePath = placementPath + ".nativeStructures[" + sourceIndex + "]";
JSONObject source = nativeStructures.optJSONObject(sourceIndex); JSONObject source = nativeStructures.optJSONObject(sourceIndex);
@@ -174,22 +391,199 @@ final class PackStructurePlacementValidator {
blockingErrors.add(sourcePath + ".structure '" + structureKey blockingErrors.add(sourcePath + ".structure '" + structureKey
+ "' is not a registered structure."); + "' is not a registered structure.");
} }
if (!structureKey.isEmpty() && !sourceKeys.add(structureKey.toLowerCase(Locale.ROOT))) {
blockingErrors.add(sourcePath + ".structure duplicates native source '" + structureKey + "'.");
}
Integer weight = PackLootValidator.lootInteger(source, "weight", 1, sourcePath, blockingErrors); Integer weight = PackLootValidator.lootInteger(source, "weight", 1, sourcePath, blockingErrors);
PackLootValidator.requireMinimum(sourcePath + ".weight", weight, 1, blockingErrors); PackLootValidator.requireMinimum(sourcePath + ".weight", weight, 1, blockingErrors);
if (weight != null && weight > 0) {
totalWeight += weight;
}
String normalizedStructureKey = structureKey.toLowerCase(Locale.ROOT);
boolean registeredJigsaw = registeredJigsaws.contains(normalizedStructureKey);
JSONObject jigsaw = source.optJSONObject("jigsaw"); JSONObject jigsaw = source.optJSONObject("jigsaw");
if (source.has("jigsaw") && source.opt("jigsaw") != JSONObject.NULL && jigsaw == null) { if (source.has("jigsaw") && source.opt("jigsaw") != JSONObject.NULL && jigsaw == null) {
blockingErrors.add(sourcePath + ".jigsaw must be an object."); blockingErrors.add(sourcePath + ".jigsaw must be an object.");
} else if (jigsaw != null) { } else if (jigsaw != null) {
if (!registeredJigsaws.isEmpty() if (!registeredJigsaws.isEmpty() && !registeredJigsaw) {
&& !registeredJigsaws.contains(structureKey.toLowerCase(Locale.ROOT))) {
blockingErrors.add(sourcePath blockingErrors.add(sourcePath
+ ".jigsaw requires a registered jigsaw structure."); + ".jigsaw requires a registered jigsaw structure.");
} }
validateJigsawAssembly( validateJigsawAssembly(
sourcePath + ".jigsaw", jigsaw, registeredPools, blockingErrors); sourcePath + ".jigsaw", jigsaw, registeredPools, blockingErrors);
} }
JigsawSourceMetadata sourceMetadata = registeredJigsaw
? resolveJigsawMetadata(sourcePath, normalizedStructureKey,
jigsawMetadataResolver, blockingErrors)
: null;
if (registeredJigsaw && sourceMetadata == null) {
continue;
}
if (registeredJigsaw || jigsaw != null) {
validateReferenceEnvelope(
sourcePath, normalizedStructureKey, jigsaw,
sourceMetadata, hooks, blockingErrors);
}
} }
validateNativeTerrain(placementPath, placement, blockingErrors); if (totalWeight > Integer.MAX_VALUE) {
blockingErrors.add(placementPath + ".nativeStructures total weight exceeds "
+ Integer.MAX_VALUE + ".");
}
}
private static void validateReferenceEnvelope(String sourcePath, String structureKey,
JSONObject jigsaw, JigsawSourceMetadata sourceMetadata,
PlatformStructureHooks hooks,
List<String> blockingErrors) {
boolean overriddenDistance = jigsaw != null && jigsaw.has("maxDistanceHorizontal");
Integer maximumDistance = overriddenDistance
? integerValue(jigsaw, "maxDistanceHorizontal", -1)
: sourceMetadata == null ? null : sourceMetadata.maxDistanceHorizontal();
if (maximumDistance == null || maximumDistance < 0) {
return;
}
Integer startElementSpan = effectiveStartElementSpan(
sourcePath, structureKey, jigsaw, sourceMetadata, hooks, blockingErrors);
if (startElementSpan == null) {
return;
}
long assemblySpan = Math.max(maximumDistance, startElementSpan);
if (assemblySpan > 128L) {
String distancePath = overriddenDistance
? ".jigsaw.maxDistanceHorizontal" : " registered jigsaw source max-distance";
blockingErrors.add(sourcePath + distancePath + " and its actual structure content"
+ " (" + startElementSpan + "-block maximum start element and "
+ maximumDistance + "-block maximum assembly distance) must not exceed Minecraft's"
+ " 128-block (8-chunk) structure reference range.");
}
}
private static Integer effectiveStartElementSpan(String sourcePath, String structureKey,
JSONObject jigsaw,
JigsawSourceMetadata sourceMetadata,
PlatformStructureHooks hooks,
List<String> blockingErrors) {
int sourceSpan = sourceMetadata == null ? 0 : sourceMetadata.maxStartElementHorizontalSpan();
if (jigsaw == null || !jigsaw.has("startPool")) {
return sourceSpan;
}
String startPool = jigsaw.optString("startPool", "").trim();
if (startPool.isEmpty() || hooks == null) {
return sourceSpan;
}
try {
int resolvedSpan = hooks.jigsawStartPoolHorizontalSpan(structureKey, startPool);
if (resolvedSpan < 0) {
throw new IllegalStateException("negative horizontal span " + resolvedSpan);
}
return resolvedSpan;
} catch (RuntimeException | LinkageError error) {
IrisLogging.reportError("Could not resolve the effective native jigsaw start-pool span", error);
blockingErrors.add(sourcePath + ".jigsaw.startPool '" + startPool
+ "' could not resolve a bounded live horizontal span: "
+ failureMessage(error) + ".");
return null;
}
}
private static void validateImportedStructureAdjustmentEnvelopes(
String dimensionKey, JSONObject dimension, RegistrySnapshot registries,
List<String> blockingErrors) {
JSONObject policy = dimension.optJSONObject("importedStructures");
if (policy == null) {
return;
}
JSONArray adjustments = policy.optJSONArray("adjustments");
if (adjustments == null) {
return;
}
List<String> orderedStructureKeys = new ArrayList<>(registries.structures());
orderedStructureKeys.sort(String::compareTo);
Map<String, EffectiveTerrainAdjustment> effective = new HashMap<>();
for (int adjustmentIndex = 0; adjustmentIndex < adjustments.length(); adjustmentIndex++) {
JSONObject adjustment = adjustments.optJSONObject(adjustmentIndex);
if (adjustment == null || adjustment.optJSONObject("terrain") == null) {
continue;
}
JSONArray matches = adjustment.optJSONArray("match");
if (matches == null || matches.length() == 0) {
continue;
}
String path = "Dimension '" + dimensionKey
+ "' importedStructures.adjustments[" + adjustmentIndex + "]";
for (String structureKey : orderedStructureKeys) {
if (matchesStructure(matches, structureKey)) {
effective.put(structureKey, new EffectiveTerrainAdjustment(path, adjustment));
}
}
}
for (String structureKey : orderedStructureKeys) {
EffectiveTerrainAdjustment adjustment = effective.get(structureKey);
if (adjustment == null) {
continue;
}
JSONObject terrain = adjustment.adjustment().optJSONObject("terrain");
if (terrain == null || !TERRAIN_ENVELOPE_MODES.contains(
terrain.optString("mode", "SOURCE").toUpperCase(Locale.ROOT))) {
continue;
}
String sourcePath = adjustment.path() + " matched registered structure '"
+ structureKey + "'";
if (registries.jigsaws().contains(structureKey)) {
JigsawSourceMetadata metadata = resolveJigsawMetadata(
sourcePath, structureKey,
registries.jigsawMetadataResolver(), blockingErrors);
if (metadata == null) {
continue;
}
validateReferenceEnvelope(
sourcePath, structureKey, null, metadata,
registries.hooks(), blockingErrors);
}
}
}
private static boolean matchesStructure(JSONArray matches, String structureKey) {
for (int matchIndex = 0; matchIndex < matches.length(); matchIndex++) {
Object rawPattern = matches.opt(matchIndex);
if (rawPattern instanceof String pattern
&& matchesStructurePattern(pattern, structureKey)) {
return true;
}
}
return false;
}
private static JigsawSourceMetadata resolveJigsawMetadata(
String sourcePath, String structureKey,
JigsawMetadataResolver resolver, List<String> blockingErrors) {
if (resolver == null) {
return null;
}
MetadataResolution resolution = resolver.resolve(structureKey);
if (resolution.metadata() != null) {
return resolution.metadata();
}
blockingErrors.add(sourcePath + " could not resolve live metadata for registered jigsaw source '"
+ structureKey + "': " + resolution.failure() + ".");
return null;
}
private static boolean matchesStructurePattern(String pattern, String structureKey) {
String normalizedPattern = pattern == null ? "" : pattern.trim().toLowerCase(Locale.ROOT);
String normalizedKey = structureKey == null ? "" : structureKey.trim().toLowerCase(Locale.ROOT);
if (normalizedPattern.isEmpty() || !normalizedKey.startsWith(normalizedPattern)) {
return false;
}
if (normalizedKey.length() == normalizedPattern.length()) {
return true;
}
char patternEnd = normalizedPattern.charAt(normalizedPattern.length() - 1);
if (patternEnd == ':' || patternEnd == '/' || patternEnd == '_') {
return true;
}
char boundary = normalizedKey.charAt(normalizedPattern.length());
return boundary == '/' || boundary == '_';
} }
private static void validateJigsawAssembly(String path, JSONObject assembly, private static void validateJigsawAssembly(String path, JSONObject assembly,
@@ -234,6 +628,8 @@ final class PackStructurePlacementValidator {
} }
PackJsonFieldChecks.validateOptionalEnum(path + ".terrain", terrain, "mode", PackJsonFieldChecks.validateOptionalEnum(path + ".terrain", terrain, "mode",
Set.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "ENCASE"), blockingErrors); Set.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "ENCASE"), blockingErrors);
PackJsonFieldChecks.validateOptionalEnum(path + ".terrain", terrain, "shape",
Set.of("BOX", "ROUNDED", "ERODED"), blockingErrors);
PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain, PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain,
"horizontalPadding", 0, 128, blockingErrors); "horizontalPadding", 0, 128, blockingErrors);
PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain, PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain,
@@ -386,4 +782,48 @@ final class PackStructurePlacementValidator {
default -> "Resource"; default -> "Resource";
}; };
} }
private record RegistrySnapshot(Set<String> structures, Set<String> jigsaws, Set<String> pools,
JigsawMetadataResolver jigsawMetadataResolver,
PlatformStructureHooks hooks) {
}
private static final class JigsawMetadataResolver {
private final PlatformStructureHooks hooks;
private final Map<String, MetadataResolution> resolutions = new HashMap<>();
private JigsawMetadataResolver(PlatformStructureHooks hooks) {
this.hooks = hooks;
}
private MetadataResolution resolve(String structureKey) {
MetadataResolution cached = resolutions.get(structureKey);
if (cached != null) {
return cached;
}
MetadataResolution resolved;
try {
JigsawSourceMetadata metadata = hooks.jigsawSourceMetadata(structureKey);
if (metadata == null) {
throw new IllegalStateException(
"The active structure registry returned null jigsaw metadata for '"
+ structureKey + "'");
}
resolved = new MetadataResolution(metadata, null);
} catch (RuntimeException | LinkageError error) {
IrisLogging.reportError(
"Could not resolve live jigsaw metadata for registered structure '"
+ structureKey + "' during pack validation", error);
resolved = new MetadataResolution(null, failureMessage(error));
}
resolutions.put(structureKey, resolved);
return resolved;
}
}
private record MetadataResolution(JigsawSourceMetadata metadata, String failure) {
}
private record EffectiveTerrainAdjustment(String path, JSONObject adjustment) {
}
} }
@@ -45,6 +45,14 @@ public final class PackValidator {
} }
public static PackValidationResult validate(File packFolder) { public static PackValidationResult validate(File packFolder) {
return validate(packFolder, true);
}
public static PackValidationResult validateForDatapackBootstrap(File packFolder) {
return validate(packFolder, false);
}
private static PackValidationResult validate(File packFolder, boolean validateLiveRegistries) {
String packName = packFolder == null ? "<unknown>" : packFolder.getName(); String packName = packFolder == null ? "<unknown>" : packFolder.getName();
List<String> blockingErrors = new ArrayList<>(); List<String> blockingErrors = new ArrayList<>();
List<String> warnings = new ArrayList<>(); List<String> warnings = new ArrayList<>();
@@ -72,7 +80,8 @@ public final class PackValidator {
blockingErrors.addAll(PackObjectSurfaceValidator.validateRemovedWorldgenFields(packFolder)); blockingErrors.addAll(PackObjectSurfaceValidator.validateRemovedWorldgenFields(packFolder));
blockingErrors.addAll(PackObjectSurfaceValidator.validateObjectSurfaceSupport(packFolder)); blockingErrors.addAll(PackObjectSurfaceValidator.validateObjectSurfaceSupport(packFolder));
blockingErrors.addAll(PackObjectSurfaceValidator.validateUnsupportedStructureTransforms(packFolder)); blockingErrors.addAll(PackObjectSurfaceValidator.validateUnsupportedStructureTransforms(packFolder));
blockingErrors.addAll(PackObjectSurfaceValidator.validateStructureGraph(packFolder)); blockingErrors.addAll(PackObjectSurfaceValidator.validateStructureGraph(
packFolder, validateLiveRegistries));
StructureGraphPackValidator.Validation compiledStructures = StructureGraphPackValidator.Validation compiledStructures =
StructureGraphPackValidator.validate( StructureGraphPackValidator.validate(
packFolder.toPath(), PackObjectSurfaceValidator.collectPlacedStructureKeys(packFolder)); packFolder.toPath(), PackObjectSurfaceValidator.collectPlacedStructureKeys(packFolder));
@@ -18,52 +18,213 @@
package art.arcane.iris.core.project; package art.arcane.iris.core.project;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.format.Form;
import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.io.IO;
import art.arcane.volmlib.util.json.JSONObject; import art.arcane.volmlib.util.json.JSONObject;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.util.Comparator; import java.util.Comparator;
import java.util.Objects;
import java.util.stream.Stream; import java.util.stream.Stream;
public final class IrisProjectCopier { public final class IrisProjectCopier {
private IrisProjectCopier() { private IrisProjectCopier() {
} }
public static void copyProject(File sourcePack, File targetPack, String sourceKey, String targetKey) throws IOException { public static void copyProject(File sourcePack, File targetWorkspace, String sourceKey, String targetKey) throws IOException {
Path source = sourcePack.toPath(); copyProject(sourcePack, targetWorkspace, sourceKey, targetKey, (source, target) -> {
try (Stream<Path> walk = Files.walk(source)) { });
for (Path path : walk.sorted(Comparator.naturalOrder()).toList()) { }
String relative = source.relativize(path).toString();
if (relative.isEmpty() || relative.equals(".git") || relative.startsWith(".git" + File.separator) || relative.endsWith(".code-workspace")) {
continue;
}
Path destination = targetPack.toPath().resolve(relative);
if (Files.isDirectory(path)) {
Files.createDirectories(destination);
} else {
Files.createDirectories(destination.getParent());
Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING);
}
}
}
File oldDimension = new File(targetPack, "dimensions/" + sourceKey + ".json"); static void copyProject(
File newDimension = new File(targetPack, "dimensions/" + targetKey + ".json"); File sourcePack,
if (oldDimension.isFile() && !oldDimension.equals(newDimension)) { File targetWorkspace,
Files.copy(oldDimension.toPath(), newDimension.toPath(), StandardCopyOption.REPLACE_EXISTING); String sourceKey,
Files.delete(oldDimension.toPath()); String targetKey,
} CopyHook copyHook
if (newDimension.isFile()) { ) throws IOException {
JSONObject json = new JSONObject(IO.readAll(newDimension)); String validatedSourceKey = requireSafeKey(sourceKey, "source");
if (json.has("name")) { String validatedTargetKey = requireSafeKey(targetKey, "target");
json.put("name", Form.capitalizeWords(targetKey.replaceAll("\\Q-\\E", " "))); Path source = requireSafeSource(
IO.writeAll(newDimension, json.toString(4)); Objects.requireNonNull(sourcePack, "sourcePack").toPath().toAbsolutePath().normalize(),
validatedSourceKey
);
Path workspace = requireTargetWorkspace(targetWorkspace);
Path target = workspace.resolve(validatedTargetKey).normalize();
requireAvailableTarget(target, workspace, validatedTargetKey);
Path stage = Files.createTempDirectory(workspace, "." + validatedTargetKey + ".importing-");
boolean published = false;
Throwable operationFailure = null;
try {
copyTree(source, stage, Objects.requireNonNull(copyHook, "copyHook"));
transformDimension(stage, validatedSourceKey, validatedTargetKey);
requireAvailableTarget(target, workspace, validatedTargetKey);
publish(stage, target);
published = true;
} catch (IOException | RuntimeException | Error e) {
operationFailure = e;
throw e;
} finally {
if (!published) {
try {
deleteTree(stage);
} catch (IOException cleanupFailure) {
if (operationFailure != null) {
operationFailure.addSuppressed(cleanupFailure);
} else {
throw cleanupFailure;
}
}
} }
} }
} }
private static String requireSafeKey(String value, String purpose) throws IOException {
if (value == null || value.isBlank()) {
throw new IOException("Project " + purpose + " key cannot be empty.");
}
Path key;
try {
key = Path.of(value);
} catch (RuntimeException e) {
throw new IOException("Invalid project " + purpose + " key: " + value, e);
}
if (key.isAbsolute()
|| key.getNameCount() != 1
|| !key.normalize().equals(key)
|| ".".equals(value)
|| "..".equals(value)
|| value.indexOf('/') >= 0
|| value.indexOf('\\') >= 0) {
throw new IOException("Invalid project " + purpose + " key: " + value);
}
return value;
}
private static Path requireTargetWorkspace(File targetWorkspace) throws IOException {
Path workspace = Objects.requireNonNull(targetWorkspace, "targetWorkspace").toPath().toAbsolutePath().normalize();
if (Files.isSymbolicLink(workspace)
|| !Files.isDirectory(workspace, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Project workspace is missing or unsafe: " + workspace);
}
return workspace;
}
private static Path requireSafeSource(Path source, String sourceKey) throws IOException {
if (!Files.isDirectory(source)) {
throw new IOException("Source project is missing or unsafe: " + source);
}
PackDirectoryResolver.requireSafePackTree(source.toFile());
Path resolvedSource = source.toRealPath();
Path dimension = resolvedSource.resolve("dimensions").resolve(sourceKey + ".json").normalize();
if (!dimension.startsWith(resolvedSource)
|| Files.isSymbolicLink(dimension)
|| !Files.isRegularFile(dimension, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Source project dimension is missing or unsafe: " + dimension);
}
return resolvedSource;
}
private static void requireAvailableTarget(Path target, Path workspace, String targetKey) throws IOException {
if (!workspace.equals(target.getParent()) || !targetKey.equals(target.getFileName().toString())) {
throw new IOException("Target project must be a direct child of the workspace: " + target);
}
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
throw new FileAlreadyExistsException(target.toString());
}
}
private static void copyTree(Path source, Path stage, CopyHook copyHook) throws IOException {
try (Stream<Path> walk = Files.walk(source)) {
for (Path path : walk.sorted(Comparator.naturalOrder()).toList()) {
Path relative = source.relativize(path);
if (relative.toString().isEmpty() || shouldSkip(relative)) {
continue;
}
if (Files.isSymbolicLink(path)) {
throw new IOException("Source project contains a symbolic link: " + path);
}
Path destination = stage.resolve(relative).normalize();
if (!destination.startsWith(stage)) {
throw new IOException("Source project entry escapes staging: " + relative);
}
copyHook.beforeCopy(path, destination);
if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(destination);
} else if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
Files.createDirectories(destination.getParent());
Files.copy(path, destination);
} else {
throw new IOException("Source project contains an unsupported entry: " + path);
}
}
}
}
private static boolean shouldSkip(Path relative) {
return ".git".equals(relative.getName(0).toString())
|| relative.getFileName().toString().endsWith(".code-workspace");
}
private static void transformDimension(Path stage, String sourceKey, String targetKey) throws IOException {
Path oldDimension = stage.resolve("dimensions").resolve(sourceKey + ".json");
Path newDimension = stage.resolve("dimensions").resolve(targetKey + ".json");
if (!oldDimension.equals(newDimension)) {
Files.move(oldDimension, newDimension);
}
JSONObject json = new JSONObject(IO.readAll(newDimension.toFile()));
if (json.has("name")) {
json.put("name", Form.capitalizeWords(targetKey.replace('-', ' ')));
IO.writeAll(newDimension.toFile(), json.toString(4));
}
}
private static void publish(Path stage, Path target) throws IOException {
try {
Files.move(stage, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
Files.move(stage, target);
}
}
private static void deleteTree(Path root) throws IOException {
if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try (Stream<Path> walk = Files.walk(root)) {
IOException failure = null;
for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
if (failure == null) {
failure = e;
} else {
failure.addSuppressed(e);
}
}
}
if (failure != null) {
throw failure;
}
}
}
@FunctionalInterface
interface CopyHook {
void beforeCopy(Path source, Path target) throws IOException;
}
} }
@@ -536,7 +536,7 @@ public class SchemaBuilder {
} else if (SchemaKeyedTypes.isKeyed(k.getType())) { } else if (SchemaKeyedTypes.isKeyed(k.getType())) {
fancyType = addEnum(k.getType(), prop, description, SchemaKeyedTypes.values(k.getType()), Function.identity()); fancyType = addEnum(k.getType(), prop, description, SchemaKeyedTypes.values(k.getType()), Function.identity());
} else if (k.getType().isEnum()) { } else if (k.getType().isEnum()) {
fancyType = addEnum(k.getType(), prop, description, k.getType().getEnumConstants(), o -> ((Enum<?>) o).name()); fancyType = addEnum(k.getType(), prop, description, enumNames(k.getType()), Function.identity());
} }
} }
case "object" -> { case "object" -> {
@@ -738,7 +738,7 @@ public class SchemaBuilder {
} else if (SchemaKeyedTypes.isKeyed(t.type())) { } else if (SchemaKeyedTypes.isKeyed(t.type())) {
fancyType = addEnumList(prop, description, t, SchemaKeyedTypes.values(t.type()), Function.identity()); fancyType = addEnumList(prop, description, t, SchemaKeyedTypes.values(t.type()), Function.identity());
} else if (t.type().isEnum()) { } else if (t.type().isEnum()) {
fancyType = addEnumList(prop, description, t, t.type().getEnumConstants(), o -> ((Enum<?>) o).name()); fancyType = addEnumList(prop, description, t, enumNames(t.type()), Function.identity());
} }
} }
} }
@@ -851,6 +851,22 @@ public class SchemaBuilder {
return "List of " + s + "s"; return "List of " + s + "s";
} }
private static String[] enumNames(Class<?> enumType) {
try {
Object[] constants = enumType.getEnumConstants();
String[] names = new String[constants.length];
for (int index = 0; index < constants.length; index++) {
names[index] = ((Enum<?>) constants[index]).name();
}
return names;
} catch (LinkageError error) {
return Arrays.stream(enumType.getDeclaredFields())
.filter(Field::isEnumConstant)
.map(Field::getName)
.toArray(String[]::new);
}
}
@NotNull @NotNull
private <T> String addEnum(Class<?> type, JSONObject prop, KList<String> description, T[] values, Function<T, String> function) { private <T> String addEnum(Class<?> type, JSONObject prop, KList<String> description, T[] values, Function<T, String> function) {
JSONArray a = new JSONArray(); JSONArray a = new JSONArray();
@@ -2,9 +2,13 @@ package art.arcane.iris.core.runtime;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.link.MultiverseCoreLink;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.core.lifecycle.WorldLifecycleService; import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.project.IrisProject; import art.arcane.iris.core.project.IrisProject;
import art.arcane.iris.core.project.IrisCodeWorkspace; import art.arcane.iris.core.project.IrisCodeWorkspace;
import art.arcane.iris.core.tools.IrisCreator; import art.arcane.iris.core.tools.IrisCreator;
@@ -14,7 +18,6 @@ import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J; import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.exceptions.IrisException; import art.arcane.volmlib.util.exceptions.IrisException;
import art.arcane.volmlib.util.bukkit.WorldIdentity; import art.arcane.volmlib.util.bukkit.WorldIdentity;
import art.arcane.volmlib.util.io.IO;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.World; import org.bukkit.World;
@@ -22,17 +25,27 @@ import org.bukkit.entity.Player;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Supplier;
public final class StudioOpenCoordinator { public final class StudioOpenCoordinator {
private static final long STUDIO_CLOSE_TIMEOUT_SECONDS = 120L;
private static volatile StudioOpenCoordinator instance; private static volatile StudioOpenCoordinator instance;
private StudioOpenCoordinator() { private StudioOpenCoordinator() {
@@ -61,31 +74,20 @@ public final class StudioOpenCoordinator {
} }
public CompletableFuture<StudioCloseResult> closeProject(IrisProject project) { public CompletableFuture<StudioCloseResult> closeProject(IrisProject project) {
CompletableFuture<StudioCloseResult> future = new CompletableFuture<>();
J.aBukkit(() -> future.complete(executeClose(project)));
return future;
}
private StudioCloseResult executeClose(IrisProject project) {
if (project == null) { if (project == null) {
return new StudioCloseResult(null, true, true, false, null); return CompletableFuture.completedFuture(new StudioCloseResult(null, true, true, false, null));
} }
PlatformChunkGenerator provider = project.getActiveProvider(); PlatformChunkGenerator provider = project.getActiveProvider();
if (provider == null) { if (provider == null) {
return new StudioCloseResult(null, true, true, false, null); return CompletableFuture.completedFuture(new StudioCloseResult(null, true, true, false, null));
} }
World world = BukkitWorldBinding.world(provider.getTarget().getWorld()); World world = BukkitWorldBinding.world(provider.getTarget().getWorld());
String worldName = world == null String worldName = world == null
? IrisWorldStorage.logicalName(WorldIdentity.parse(provider.getTarget().getWorld().identity())) ? IrisWorldStorage.logicalName(WorldIdentity.parse(provider.getTarget().getWorld().identity()))
: IrisWorldStorage.logicalName(world); : IrisWorldStorage.logicalName(world);
try { return closeWorldCoordinated(provider, worldName, world, true, project);
return closeWorld(provider, worldName, world, true, project);
} catch (Throwable e) {
project.setActiveProvider(null);
return new StudioCloseResult(worldName, false, false, false, e);
}
} }
private void executeOpen(StudioOpenRequest request, CompletableFuture<StudioOpenResult> future) { private void executeOpen(StudioOpenRequest request, CompletableFuture<StudioOpenResult> future) {
@@ -200,7 +202,16 @@ public final class StudioOpenCoordinator {
if (!request.retainOnFailure()) { if (!request.retainOnFailure()) {
try { try {
updateStage(request, "cleanup", 1.00D); updateStage(request, "cleanup", 1.00D);
closeWorld(provider, request.worldName(), world, true, request.project()); StudioCloseResult cleanupResult = closeWorldCoordinated(
provider,
request.worldName(),
world,
true,
request.project()
).get(45L, TimeUnit.SECONDS);
if (cleanupResult.failureCause() != null) {
throw cleanupResult.failureCause();
}
} catch (Throwable cleanupError) { } catch (Throwable cleanupError) {
IrisLogging.reportError("Studio cleanup failed for world \"" + request.worldName() + "\".", cleanupError); IrisLogging.reportError("Studio cleanup failed for world \"" + request.worldName() + "\".", cleanupError);
} }
@@ -240,164 +251,344 @@ public final class StudioOpenCoordinator {
return loaded; return loaded;
} }
private StudioCloseResult closeWorld( private CompletableFuture<StudioCloseResult> closeWorldCoordinated(
PlatformChunkGenerator provider, PlatformChunkGenerator provider,
String worldName, String worldName,
World world, World world,
boolean deleteFolder, boolean deleteFolder,
IrisProject project IrisProject project
) { ) {
Throwable failure = null; String operationTarget = worldName == null || worldName.isBlank() ? "unknown-studio-world" : worldName;
boolean unloadCompletedLive = world == null || !isWorldFamilyLoaded(worldName); LifecycleOperationCoordinator.Lease lease;
boolean folderDeletionCompletedLive = !deleteFolder; try {
boolean startupCleanupQueued = false; lease = LifecycleOperationCoordinator.get().acquire(
CompletableFuture<Void> closeFuture = CompletableFuture.completedFuture(null); LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.STUDIO_CLOSE,
if (world != null) { operationTarget
try { );
evacuatePlayers(world); } catch (Throwable failure) {
} catch (Throwable e) { boolean queued = deleteFolder && queueStartupCleanup(worldName, failure);
failure = e; return CompletableFuture.completedFuture(new StudioCloseResult(
} worldName,
false,
false,
queued,
failure
));
} }
CompletableFuture<StudioCloseResult> closeFuture;
try {
closeFuture = closeWorldReserved(provider, worldName, world, deleteFolder, project);
} catch (Throwable failure) {
boolean queued = deleteFolder && queueStartupCleanup(worldName, failure);
closeFuture = CompletableFuture.completedFuture(new StudioCloseResult(
worldName,
false,
false,
queued,
failure
));
}
return closeFuture.whenComplete((result, throwable) -> lease.close());
}
private CompletableFuture<StudioCloseResult> closeWorldReserved(
PlatformChunkGenerator provider,
String worldName,
World world,
boolean deleteFolder,
IrisProject project
) {
AtomicBoolean unloadConfirmed = new AtomicBoolean(false);
AtomicBoolean folderDeleted = new AtomicBoolean(!deleteFolder);
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
if (world != null) { if (world != null) {
IrisToolbelt.beginWorldMaintenance(world, "studio-close", true); IrisToolbelt.beginWorldMaintenance(world, "studio-close", true);
} }
try { CompletableFuture<Void> sequence = sequenceStudioClose(
if (project != null) { () -> evacuateWorldFamily(worldName, world),
project.setActiveProvider(null); () -> unloadWorldFamily(worldName, world).thenRun(() -> {
} if (terminalTimeout.get()) {
if (provider != null) { throw new CompletionException(new TimeoutException(
closeFuture = provider.closeAsync(); "Studio close stopped after its terminal timeout."));
} }
unloadConfirmed.set(true);
if (worldName != null && !worldName.isBlank()) { if (project != null) {
requestWorldFamilyUnload(worldName); project.setActiveProvider(null);
} }
}),
if (worldName != null && !worldName.isBlank()) { () -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(),
long unloadDeadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(20L); () -> deleteFolder
CompletableFuture<Void> unloadFuture = waitForWorldFamilyUnload(worldName, unloadDeadline); ? deleteWorldFamily(worldName).thenRun(() -> folderDeleted.set(true))
try { : CompletableFuture.completedFuture(null),
unloadFuture.get(Math.max(1000L, unloadDeadline - System.currentTimeMillis()), TimeUnit.MILLISECONDS); terminalTimeout::get
unloadCompletedLive = true; );
} catch (TimeoutException e) { CompletableFuture<StudioCloseResult> operation = guardCloseCompletion(
unloadCompletedLive = !isWorldFamilyLoaded(worldName); sequence,
} catch (Throwable e) { terminalTimeout,
failure = failure == null ? unwrapFailure(e) : failure; worldName)
} .thenApply(ignored -> new StudioCloseResult(
} worldName,
true,
try { folderDeleted.get(),
closeFuture.get(20L, TimeUnit.SECONDS); false,
} catch (Throwable e) { null
Throwable cause = unwrapFailure(e); ))
if (failure == null) { .exceptionally(throwable -> {
failure = cause; Throwable failure = unwrapFailure(throwable);
} boolean queued = deleteFolder && queueStartupCleanup(worldName, failure);
} return new StudioCloseResult(
worldName,
if (deleteFolder && worldName != null && !worldName.isBlank()) { unloadConfirmed.get(),
WorldFamilyDeleteResult deleteResult = deleteWorldFamily(worldName, unloadCompletedLive); folderDeleted.get(),
folderDeletionCompletedLive = deleteResult.liveDeleted(); queued,
startupCleanupQueued = deleteResult.startupCleanupQueued(); failure
} );
} finally { });
return operation.whenComplete((result, throwable) -> {
if (world != null) { if (world != null) {
IrisToolbelt.endWorldMaintenance(world, "studio-close"); IrisToolbelt.endWorldMaintenance(world, "studio-close");
} }
} });
return new StudioCloseResult(worldName, unloadCompletedLive, folderDeletionCompletedLive, startupCleanupQueued, failure);
} }
private void evacuatePlayers(World world) throws Exception { static CompletableFuture<Void> sequenceStudioClose(
if (world == null) { Supplier<CompletableFuture<Void>> evacuate,
return; Supplier<CompletableFuture<Void>> unload,
Supplier<CompletableFuture<Void>> closeGenerator,
Supplier<CompletableFuture<Void>> deleteFolders
) {
return sequenceStudioClose(evacuate, unload, closeGenerator, deleteFolders, () -> false);
}
static CompletableFuture<Void> sequenceStudioClose(
Supplier<CompletableFuture<Void>> evacuate,
Supplier<CompletableFuture<Void>> unload,
Supplier<CompletableFuture<Void>> closeGenerator,
Supplier<CompletableFuture<Void>> deleteFolders,
BooleanSupplier terminalTimeout
) {
return invokePhase(evacuate)
.thenCompose(ignored -> invokePhaseUnlessTimedOut(unload, terminalTimeout))
.thenCompose(ignored -> invokePhaseUnlessTimedOut(closeGenerator, terminalTimeout))
.thenCompose(ignored -> invokePhaseUnlessTimedOut(deleteFolders, terminalTimeout));
}
private static CompletableFuture<Void> invokePhase(Supplier<CompletableFuture<Void>> phase) {
try {
CompletableFuture<Void> future = phase.get();
if (future == null) {
return CompletableFuture.failedFuture(new IllegalStateException("Studio close phase returned no completion future."));
}
return future;
} catch (Throwable failure) {
return CompletableFuture.failedFuture(failure);
}
}
private static CompletableFuture<Void> invokePhaseUnlessTimedOut(
Supplier<CompletableFuture<Void>> phase,
BooleanSupplier terminalTimeout
) {
if (terminalTimeout.getAsBoolean()) {
return CompletableFuture.failedFuture(new TimeoutException(
"Studio close stopped after its terminal timeout."));
}
return invokePhase(phase);
}
private CompletableFuture<Void> guardCloseCompletion(
CompletableFuture<Void> source,
AtomicBoolean terminalTimeout,
String worldName
) {
CompletableFuture<Void> guarded = new CompletableFuture<>();
AtomicBoolean settled = new AtomicBoolean(false);
source.whenComplete((ignored, throwable) -> {
if (!settled.compareAndSet(false, true)) {
return;
}
if (throwable == null) {
guarded.complete(null);
} else {
guarded.completeExceptionally(throwable);
}
});
CompletableFuture.delayedExecutor(STUDIO_CLOSE_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> {
if (!settled.compareAndSet(false, true)) {
return;
}
terminalTimeout.set(true);
TimeoutException timeout = new TimeoutException(
"Studio close did not settle within " + STUDIO_CLOSE_TIMEOUT_SECONDS
+ " seconds for \"" + worldName + "\".");
ServerConfigurator.restart("Studio close timed out for \"" + worldName + "\".");
guarded.completeExceptionally(timeout);
});
return guarded;
}
private CompletableFuture<Void> evacuateWorldFamily(String worldName, World primaryWorld) {
List<World> loadedWorlds = loadedWorldFamily(worldName, primaryWorld);
if (loadedWorlds.isEmpty()) {
return CompletableFuture.completedFuture(null);
} }
CompletableFuture<Void> future = J.sfut(() -> { ArrayList<CompletableFuture<Void>> evacuations = new ArrayList<>(loadedWorlds.size());
IrisToolbelt.evacuate(world); for (World loadedWorld : loadedWorlds) {
CompletableFuture<Void> evacuation = J.sfut(() -> IrisToolbelt.evacuateAsync(loadedWorld))
.thenCompose(evacuationFuture -> evacuationFuture)
.thenCompose(evacuated -> Boolean.TRUE.equals(evacuated)
? CompletableFuture.completedFuture(null)
: CompletableFuture.failedFuture(new IllegalStateException(
"Studio player evacuation failed for \"" + loadedWorld.getName() + "\".")));
evacuations.add(evacuation);
}
return CompletableFuture.allOf(evacuations.toArray(CompletableFuture[]::new));
}
private CompletableFuture<Void> unloadWorldFamily(String worldName, World primaryWorld) {
List<World> loadedWorlds = loadedWorldFamily(worldName, primaryWorld);
ArrayList<CompletableFuture<Boolean>> unloads = new ArrayList<>(loadedWorlds.size());
for (World loadedWorld : loadedWorlds) {
CompletableFuture<Boolean> unload = J.sfut(() ->
IrisServices.get(MultiverseCoreLink.class)
.removeFromConfig(loadedWorld))
.thenCompose(ignored -> WorldLifecycleService.get().unloadAsync(loadedWorld, false));
unloads.add(unload);
}
return CompletableFuture.allOf(unloads.toArray(CompletableFuture[]::new)).thenApply(ignored -> {
for (CompletableFuture<Boolean> unload : unloads) {
if (!Boolean.TRUE.equals(unload.join())) {
throw new CompletionException(new IllegalStateException(
"Studio world family unload returned false for \"" + worldName + "\"."));
}
}
if (isWorldFamilyLoaded(worldName)) {
throw new CompletionException(new IllegalStateException(
"Studio world family remained loaded after confirmed unload for \"" + worldName + "\"."));
}
return null; return null;
}); });
if (future != null) {
future.get(10L, TimeUnit.SECONDS);
}
} }
private void requestWorldFamilyUnload(String worldName) { private List<World> loadedWorldFamily(String worldName, World primaryWorld) {
LinkedHashSet<World> worlds = new LinkedHashSet<>();
if (primaryWorld != null) {
worlds.add(primaryWorld);
}
if (worldName == null || worldName.isBlank()) { if (worldName == null || worldName.isBlank()) {
return; return List.copyOf(worlds);
} }
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) { for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
World familyWorld = WorldIdentity.resolve(IrisWorldStorage.keyFromName(familyWorldName)).orElse(null); WorldIdentity.resolve(IrisWorldStorage.keyFromName(familyWorldName)).ifPresent(worlds::add);
if (familyWorld == null) {
continue;
}
IrisServices.get(art.arcane.iris.core.link.MultiverseCoreLink.class).removeFromConfig(familyWorld);
WorldLifecycleService.get().unload(familyWorld, false);
} }
return List.copyOf(worlds);
} }
private WorldFamilyDeleteResult deleteWorldFamily(String worldName, boolean unloadCompletedLive) { private CompletableFuture<Void> deleteWorldFamily(String worldName) {
if (worldName == null || worldName.isBlank()) { if (worldName == null || worldName.isBlank()) {
return new WorldFamilyDeleteResult(true, false); return CompletableFuture.completedFuture(null);
}
if (isWorldFamilyLoaded(worldName)) {
return CompletableFuture.failedFuture(new IllegalStateException(
"Refusing to delete a loaded studio world family for \"" + worldName + "\"."));
} }
boolean liveDeleted = true; return CompletableFuture.runAsync(() -> {
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) { for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
File folder = IrisWorldStorage.dimensionRoot(familyWorldName); try {
if (!folder.exists()) { if (isWorldFamilyLoaded(worldName)) {
continue; throw new IOException("Studio world family became loaded before deletion for \""
+ worldName + "\".");
}
File folder = IrisWorldStorage.requireSafeManagedDimensionRoot(
IrisWorldStorage.managedKeyFromName(familyWorldName));
AtomicDirectoryPublisher.deleteTree(folder.toPath());
} catch (IOException | IllegalArgumentException failure) {
throw new CompletionException(failure);
}
} }
});
}
try { private boolean queueStartupCleanup(String worldName, Throwable failure) {
deleteWorldFolderAsync(folder, 40).get(15L, TimeUnit.SECONDS); if (worldName == null || worldName.isBlank()) {
} catch (Throwable e) { return false;
liveDeleted = false;
IrisLogging.reportError("Studio folder deletion retries failed for \"" + folder.getAbsolutePath() + "\".", unwrapFailure(e));
}
if (folder.exists()) {
liveDeleted = false;
}
} }
if (liveDeleted) {
return new WorldFamilyDeleteResult(true, false);
}
try { try {
IrisServices.get(WorldDeletionQueue.class).queueForStartupDeletion(Collections.singleton(worldName)); IrisServices.get(WorldDeletionQueue.class).queueFamilyForStartupDeletion(Collections.singleton(worldName));
return new WorldFamilyDeleteResult(false, true); return true;
} catch (IOException e) { } catch (Throwable queueFailure) {
if (unloadCompletedLive) { if (failure != null) {
IrisLogging.reportError("Failed to queue deferred deletion for world \"" + worldName + "\".", e); failure.addSuppressed(queueFailure);
} }
return new WorldFamilyDeleteResult(false, false); IrisLogging.reportError("Failed to queue deferred deletion for world \"" + worldName + "\".", queueFailure);
return false;
} }
} }
private void cleanupStaleTransientWorlds(String worldName) { private void cleanupStaleTransientWorlds(String worldName) {
LinkedHashSet<String> staleWorldNames = TransientWorldCleanupSupport.collectTransientStudioWorldNames(IrisWorldStorage.levelRoot()); LinkedHashSet<String> staleWorldNames = collectSafeTransientWorldNames();
String requestedBaseName = TransientWorldCleanupSupport.transientStudioBaseWorldName(worldName); String requestedBaseName = TransientWorldCleanupSupport.transientStudioBaseWorldName(worldName);
if (requestedBaseName != null) { if (requestedBaseName != null) {
staleWorldNames.add(requestedBaseName); staleWorldNames.add(requestedBaseName);
} }
for (String staleWorldName : staleWorldNames) { for (String staleWorldName : staleWorldNames) {
if (WorldIdentity.resolve(IrisWorldStorage.keyFromName(staleWorldName)).isPresent()) { try {
continue; StudioCloseResult cleanupResult = closeWorldCoordinated(
null,
staleWorldName,
null,
true,
null
).get(30L, TimeUnit.SECONDS);
if (cleanupResult.failureCause() != null) {
IrisLogging.reportError("Stale studio world cleanup failed for \"" + staleWorldName + "\".", cleanupResult.failureCause());
}
} catch (Throwable failure) {
IrisLogging.reportError("Stale studio world cleanup failed for \"" + staleWorldName + "\".", unwrapFailure(failure));
} }
deleteWorldFamily(staleWorldName, true);
} }
} }
private LinkedHashSet<String> collectSafeTransientWorldNames() {
LinkedHashSet<String> worldNames = new LinkedHashSet<>();
Path irisNamespace = IrisWorldStorage.levelRoot()
.toPath()
.toAbsolutePath()
.normalize()
.resolve("dimensions")
.resolve("iris");
if (!Files.exists(irisNamespace, LinkOption.NOFOLLOW_LINKS)) {
return worldNames;
}
if (Files.isSymbolicLink(irisNamespace) || !Files.isDirectory(irisNamespace, LinkOption.NOFOLLOW_LINKS)) {
IrisLogging.warn("Skipping stale studio cleanup because Iris dimension storage is unsafe: " + irisNamespace);
return worldNames;
}
try (DirectoryStream<Path> children = Files.newDirectoryStream(irisNamespace)) {
for (Path child : children) {
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
continue;
}
String transientName = TransientWorldCleanupSupport.transientStudioBaseWorldName(
child.getFileName().toString());
if (transientName != null) {
worldNames.add(transientName);
}
}
} catch (IOException failure) {
IrisLogging.reportError("Failed to inspect stale studio worlds in \"" + irisNamespace + "\".", failure);
}
return worldNames;
}
private void updateStage(StudioOpenRequest request, String stage, double progress) { private void updateStage(StudioOpenRequest request, String stage, double progress) {
if (request.progressConsumer() != null) { if (request.progressConsumer() != null) {
request.progressConsumer().accept(new StudioOpenProgress(progress, stage)); request.progressConsumer().accept(new StudioOpenProgress(progress, stage));
@@ -419,37 +610,6 @@ public final class StudioOpenCoordinator {
}; };
} }
private CompletableFuture<Void> waitForWorldFamilyUnload(String worldName, long deadline) {
if (worldName == null || !isWorldFamilyLoaded(worldName) || System.currentTimeMillis() >= deadline) {
return CompletableFuture.completedFuture(null);
}
return delayFuture(100L).thenCompose(ignored -> waitForWorldFamilyUnload(worldName, deadline));
}
private CompletableFuture<Void> deleteWorldFolderAsync(File folder, int attemptsRemaining) {
if (folder == null || !folder.exists()) {
return CompletableFuture.completedFuture(null);
}
IO.delete(folder);
if (!folder.exists()) {
return CompletableFuture.completedFuture(null);
}
if (attemptsRemaining <= 1) {
return CompletableFuture.failedFuture(new IllegalStateException("World folder still exists after deletion retries: " + folder.getAbsolutePath()));
}
return delayFuture(250L).thenCompose(ignored -> deleteWorldFolderAsync(folder, attemptsRemaining - 1));
}
private CompletableFuture<Void> delayFuture(long delayMillis) {
long safeDelay = Math.max(0L, delayMillis);
return CompletableFuture.runAsync(() -> {
}, CompletableFuture.delayedExecutor(safeDelay, TimeUnit.MILLISECONDS));
}
private Throwable unwrapFailure(Throwable throwable) { private Throwable unwrapFailure(Throwable throwable) {
Throwable cursor = throwable; Throwable cursor = throwable;
while (cursor instanceof CompletionException || cursor instanceof ExecutionException) { while (cursor instanceof CompletionException || cursor instanceof ExecutionException) {
@@ -538,7 +698,4 @@ public final class StudioOpenCoordinator {
return failureCause == null; return failureCause == null;
} }
} }
private record WorldFamilyDeleteResult(boolean liveDeleted, boolean startupCleanupQueued) {
}
} }
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more