diff --git a/README.md b/README.md index fba912aec..7c2dadadb 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,10 @@ Canonical English is defined in the typed Java catalogs under `core/src/main/jav |---|---|---|---| | Paper / Purpur / Leaf / Canvas | plugin jar | 26.1.2 - 26.2 | Full feature set | | Folia | plugin jar | 26.1.2 - 26.2 | Region-safe scheduling throughout | -| Spigot / CraftBukkit | plugin jar | 26.1.2 - 26.2 | Full feature set | +| Spigot / CraftBukkit | plugin jar | 26.1.2 - 26.2 | Managed `iris:*` creation and generation; exact vanilla-slot `/iris replace` is unavailable | | Fabric | mod jar | 26.2 | Server worldgen + client HUD; requires Fabric Loader 0.19.3+ | -| Forge | mod jar | 26.2 | Server worldgen + client HUD; requires Forge 65.0.4+ | -| NeoForge | mod jar | 26.2 | Server worldgen + client HUD; requires NeoForge 26.2.0.12-beta+ | +| Forge | mod jar | 26.2 | Server worldgen + client HUD; current target is Forge 26.2-65.1.1 | +| NeoForge | mod jar | 26.2 | Server worldgen + client HUD; current target is NeoForge 26.2.0.59 | Java 25 is required on every platform. @@ -63,15 +63,24 @@ content selecting on `#minecraft:is_overworld` and friends. **Plugin (Paper/Purpur/Leaf/Canvas/Folia/Spigot):** drop the plugin jar into `plugins/` and start the server. First boot performs no pack download. Run `/iris download pack=overworld`, -`/iris download pack=underworld`, or `/iris download link=https://host/path/pack.zip`, then restart manually. +`/iris download pack=underworld`, or `/iris download link=https://host/path/pack.zip`, waiting for +each download to finish before starting another. The shipping Overworld declares Towns & Towers +26.1 and Dungeons & Taverns 5.3.0. With the default automatic ingest enabled, the first restart +after download installs those external datapacks and leaves admission restart-required; complete +the ensuing clean restart so Minecraft loads them together with the Iris dimension types and +biomes. If automatic ingest is disabled, run `/iris datapack ingest restart=true` instead and +complete the restart it requests. Plain Spigot supports ordinary managed `/iris create`, but not +the early-bootstrap `/iris replace` path for canonical Overworld, Nether, or End slots. **Mod (Fabric/Forge/NeoForge):** drop the mod jar into `mods/` and start the server. The jar is self-contained (core, SPI, and required Fabric API modules are bundled). First boot compiles only packs already on disk and never accesses the network. `/iris download` installs a pack atomically -without stopping the server; restart manually afterward. Packs register their custom dimension types -(height ranges) and custom biomes through the forced datapack at server start - restart once after -adding a pack so worlds get its full heights and biomes; worlds created before that restart run -with fallback heights. +without stopping the server. Before loading the shipping Overworld, manually place the exact +compatible Towns & Towers 26.1 and Dungeons & Taverns 5.3.0 archives in that save's `datapacks/` +directory; modded `/iris datapack ingest` is an explanatory stub and does not install them. Restart +once only after the Iris pack and both external datapacks are present. Packs register their custom +dimension types, height ranges, biomes, and external structure keys during that boot; worlds created +before it run with fallback registry data. **Singleplayer (modded clients):** installed Iris packs appear as selectable World Types on the Create New World screen; the integrated server runs the same engine. @@ -89,7 +98,8 @@ mod is inert. ## Quickstart -Create and enter an Iris world. +Complete the platform's pack, external-datapack, and registry-restart workflow above first. Then +create and enter an Iris world. Plugin (optional arguments are keyed): @@ -105,6 +115,17 @@ Mod (positional arguments): /iris tp irisworldgen:myworld ``` +On Paper-family servers with early bootstrap (not plain Spigot), the shipping pair can instead +replace the canonical portal-linked slots after their registry workflow is complete: + +``` +/iris replace minecraft:overworld type=overworld seed=123456789 +/iris replace minecraft:the_nether type=underworld seed=-987654321 +``` + +Restart once after both commands report staged. The independent seeds apply to their respective +slots, and vanilla portals keep routing between the canonical Overworld and Nether identities. + Pregeneration requires a radius in blocks. On the plugin, optional arguments are keyed; on modded servers they are positional and composable: diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CurrentPaperWorldDataWriter.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CurrentPaperWorldDataWriter.java new file mode 100644 index 000000000..1b58d0b95 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CurrentPaperWorldDataWriter.java @@ -0,0 +1,130 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import art.arcane.iris.core.lifecycle.WorldReplacementSeed; +import art.arcane.iris.util.common.scheduling.J; +import io.papermc.paper.world.saveddata.PaperLevelOverrides; +import io.papermc.paper.world.saveddata.PaperWorldMetadata; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.storage.PrimaryLevelData; +import net.minecraft.world.level.storage.SavedDataStorage; +import org.bukkit.Bukkit; +import org.bukkit.craftbukkit.CraftServer; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +final class CurrentPaperWorldDataWriter { + private static final long SNAPSHOT_TIMEOUT_SECONDS = 30L; + + private CurrentPaperWorldDataWriter() { + } + + static void write( + Path sourceWorldDirectory, + Path targetWorldDirectory, + long seed + ) throws IOException { + CraftServer craftServer = (CraftServer) Bukkit.getServer(); + MinecraftServer server = craftServer.getHandle().getServer(); + PaperLevelOverrides levelOverrides = captureLevelOverrides(craftServer, server); + + Path targetWorld = targetWorldDirectory.toAbsolutePath().normalize(); + UUID metadataUuid = UUID.randomUUID(); + WorldReplacementSeed.copyWithAuthoritativeSeed(sourceWorldDirectory, targetWorld, seed); + try (SavedDataStorage savedDataStorage = new SavedDataStorage( + targetWorld.resolve("data"), + server.getFixerUpper(), + server.registryAccess() + )) { + savedDataStorage.set(PaperWorldMetadata.TYPE, new PaperWorldMetadata(metadataUuid)); + savedDataStorage.set(PaperLevelOverrides.TYPE, levelOverrides); + } + + List requiredDataFiles = List.of( + targetWorld.resolve("data/minecraft/world_gen_settings.dat"), + targetWorld.resolve("data/paper/metadata.dat"), + targetWorld.resolve("data/paper/level_overrides.dat") + ); + for (Path requiredDataFile : requiredDataFiles) { + if (!Files.isRegularFile(requiredDataFile, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Current Paper world data was not written: " + requiredDataFile); + } + } + + long writtenSeed = WorldReplacementSeed.readAuthoritativeSeed(targetWorld); + if (writtenSeed != seed) { + throw new IOException("Current Paper world data did not retain the requested seed."); + } + + try (SavedDataStorage verificationStorage = new SavedDataStorage( + targetWorld.resolve("data"), + server.getFixerUpper(), + server.registryAccess() + )) { + PaperWorldMetadata metadata = verificationStorage.get(PaperWorldMetadata.TYPE); + if (metadata == null || !metadataUuid.equals(metadata.uuid())) { + throw new IOException("Current Paper world metadata could not be verified."); + } + PaperLevelOverrides overrides = verificationStorage.get(PaperLevelOverrides.TYPE); + if (overrides == null || overrides.isInitialized()) { + throw new IOException("Current Paper level overrides could not be verified."); + } + } + } + + private static PaperLevelOverrides captureLevelOverrides( + CraftServer craftServer, + MinecraftServer server + ) throws IOException { + if (craftServer.isGlobalTickThread()) { + return createLevelOverrides(craftServer, server); + } + if (J.isFolia() && J.isPrimaryThread()) { + throw new IOException("Current Paper world data cannot be staged from a Folia region tick thread."); + } + + CompletableFuture captured = new CompletableFuture<>(); + boolean scheduled = J.runGlobal(() -> { + try { + captured.complete(createLevelOverrides(craftServer, server)); + } catch (Throwable failure) { + captured.completeExceptionally(failure); + } + }); + if (!scheduled) { + throw new IOException("Could not schedule the current Paper level-data snapshot on the global thread."); + } + + try { + return captured.get(SNAPSHOT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while capturing current Paper level data.", failure); + } catch (ExecutionException failure) { + throw new IOException("Could not capture current Paper level data.", failure.getCause()); + } catch (TimeoutException failure) { + throw new IOException("Timed out while capturing current Paper level data.", failure); + } + } + + private static PaperLevelOverrides createLevelOverrides( + CraftServer craftServer, + MinecraftServer server + ) throws IOException { + if (!craftServer.isGlobalTickThread()) { + throw new IOException("Current Paper level data must be captured on the global tick thread."); + } + if (!(server.getWorldData().overworldData() instanceof PrimaryLevelData primaryLevelData)) { + throw new IOException("Paper primary level data is unavailable for current world data staging."); + } + return PaperLevelOverrides.createFromLiveLevelData(primaryLevelData); + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/DatapackStructureStateFilter.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/DatapackStructureStateFilter.java index a50129c99..3c2346d0f 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/DatapackStructureStateFilter.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/DatapackStructureStateFilter.java @@ -7,7 +7,6 @@ import net.minecraft.core.Holder; import net.minecraft.core.HolderOwner; import net.minecraft.core.Vec3i; import net.minecraft.resources.ResourceKey; -import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; import net.minecraft.world.level.levelgen.structure.StructureSet; import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement; import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement; @@ -41,16 +40,31 @@ final class DatapackStructureStateFilter { DatapackStructureScopeIndex scopeIndex, Set declaredSources, IrisImportedStructureControl importedStructures + ) { + return filter( + structureSets, + scopeIndex, + declaredSources, + importedStructures, + keyIndex(structureSets)); + } + + static Selection filter( + List> structureSets, + DatapackStructureScopeIndex scopeIndex, + Set declaredSources, + IrisImportedStructureControl importedStructures, + StructureSetKeyIndex keyIndex ) { if (scopeIndex.isEmpty()) { return new Selection( - scaleFrequencyOverrides(structureSets, importedStructures), + scaleFrequencyOverrides(structureSets, importedStructures, keyIndex), 0, 0); } Map> holdersByKey = new HashMap<>(); for (Holder holder : structureSets) { - String key = structureSetKey(holder); + String key = structureSetKey(holder, keyIndex); if (key != null) { holdersByKey.putIfAbsent(key, holder); } @@ -61,7 +75,7 @@ final class DatapackStructureStateFilter { int retainedManagedSets = 0; int excludedManagedSets = 0; for (Holder holder : structureSets) { - String setKey = structureSetKey(holder); + String setKey = structureSetKey(holder, keyIndex); boolean managedSet = setKey != null && scopeIndex.isManagedStructureSet(setKey); Holder scopedHolder = scopeHolder( holder, @@ -69,7 +83,8 @@ final class DatapackStructureStateFilter { declaredSources, holdersByKey, scopedByIdentity, - visiting); + visiting, + keyIndex); if (scopedHolder == null) { if (managedSet) { excludedManagedSets++; @@ -82,21 +97,29 @@ final class DatapackStructureStateFilter { filteredSets.add(scopedHolder); } return new Selection( - scaleFrequencyOverrides(List.copyOf(filteredSets), importedStructures), + scaleFrequencyOverrides( + List.copyOf(filteredSets), importedStructures, keyIndex), retainedManagedSets, excludedManagedSets); } static String structureSetKey(Holder holder) { - Optional> key = holder.unwrapKey(); - if (key.isPresent()) { - return key.get().identifier().toString(); - } - if (holder.value().placement() - instanceof ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement) { - return keyedPlacement.key.identifier().toString(); - } - return null; + return structureSetKey(holder, keyIndex(List.of(holder))); + } + + static StructureSetKeyIndex keyIndex( + Iterable> registeredStructureSets + ) { + return StructureSetKeyIndex.create(registeredStructureSets); + } + + private static String structureSetKey( + Holder holder, + StructureSetKeyIndex keyIndex + ) { + return keyIndex.resolve(holder) + .map(key -> key.identifier().toString()) + .orElse(null); } private static List filterEntries( @@ -122,7 +145,8 @@ final class DatapackStructureStateFilter { Set declaredSources, Map> holdersByKey, Map, Holder> scopedByIdentity, - Set> visiting + Set> visiting, + StructureSetKeyIndex keyIndex ) { if (scopedByIdentity.containsKey(holder)) { return scopedByIdentity.get(holder); @@ -131,7 +155,7 @@ final class DatapackStructureStateFilter { return null; } try { - String setKey = structureSetKey(holder); + String setKey = structureSetKey(holder, keyIndex); if (setKey != null && scopeIndex.isManagedStructureSet(setKey) && !scopeIndex.allowsStructureSet(setKey, declaredSources)) { scopedByIdentity.put(holder, null); @@ -150,14 +174,15 @@ final class DatapackStructureStateFilter { declaredSources, holdersByKey, scopedByIdentity, - visiting); + visiting, + keyIndex); if (entries.size() == originalSet.structures().size() && placement == originalSet.placement()) { scopedByIdentity.put(holder, holder); return holder; } Holder scoped = replacementHolder( - holder, new StructureSet(entries, placement)); + holder, new StructureSet(entries, placement), keyIndex); scopedByIdentity.put(holder, scoped); return scoped; } finally { @@ -184,7 +209,8 @@ final class DatapackStructureStateFilter { Set declaredSources, Map> holdersByKey, Map, Holder> scopedByIdentity, - Set> visiting + Set> visiting, + StructureSetKeyIndex keyIndex ) { Optional currentZone = exclusionZone(placement); if (currentZone.isEmpty()) { @@ -192,7 +218,7 @@ final class DatapackStructureStateFilter { } Holder target = currentZone.get().otherSet(); - String targetKey = structureSetKey(target); + String targetKey = structureSetKey(target, keyIndex); if (targetKey != null) { target = holdersByKey.getOrDefault(targetKey, target); } @@ -202,7 +228,8 @@ final class DatapackStructureStateFilter { declaredSources, holdersByKey, scopedByIdentity, - visiting); + visiting, + keyIndex); Optional scopedZone = Optional.empty(); if (scopedTarget != null) { scopedZone = Optional.of(new StructurePlacement.ExclusionZone( @@ -217,14 +244,15 @@ final class DatapackStructureStateFilter { private static List> scaleFrequencyOverrides( List> structureSets, - IrisImportedStructureControl importedStructures + IrisImportedStructureControl importedStructures, + StructureSetKeyIndex keyIndex ) { if (!importedStructures.hasFrequencyOverrides()) { return structureSets; } Map> holdersByKey = new HashMap<>(); for (Holder holder : structureSets) { - String key = structureSetKey(holder); + String key = structureSetKey(holder, keyIndex); if (key != null) { holdersByKey.putIfAbsent(key, holder); } @@ -234,7 +262,11 @@ final class DatapackStructureStateFilter { boolean changed = false; for (Holder holder : structureSets) { Holder scaled = scaleFrequencyHolder( - holder, importedStructures, holdersByKey, scaledByIdentity); + holder, + importedStructures, + holdersByKey, + scaledByIdentity, + keyIndex); scaledSets.add(scaled); changed |= scaled != holder; } @@ -245,17 +277,19 @@ final class DatapackStructureStateFilter { Holder holder, IrisImportedStructureControl importedStructures, Map> holdersByKey, - Map, Holder> scaledByIdentity + Map, Holder> scaledByIdentity, + StructureSetKeyIndex keyIndex ) { if (scaledByIdentity.containsKey(holder)) { return scaledByIdentity.get(holder); } - if (!dependsOnFrequencyOverride(holder, importedStructures, holdersByKey)) { + if (!dependsOnFrequencyOverride( + holder, importedStructures, holdersByKey, keyIndex)) { scaledByIdentity.put(holder, holder); return holder; } - ResourceKey holderKey = structureSetResourceKey(holder).orElseThrow(() -> + ResourceKey holderKey = keyIndex.resolve(holder).orElseThrow(() -> new IllegalStateException( "An affected native structure-set exclusion graph has an unkeyed holder")); ReboundStructureSetHolder scaledHolder = new ReboundStructureSetHolder(holderKey); @@ -267,16 +301,21 @@ final class DatapackStructureStateFilter { Optional scaledZone = originalZone; if (originalZone.isPresent()) { Holder target = canonicalHolder( - originalZone.get().otherSet(), holdersByKey); + originalZone.get().otherSet(), holdersByKey, keyIndex); Holder scaledTarget = scaleFrequencyHolder( - target, importedStructures, holdersByKey, scaledByIdentity); + target, + importedStructures, + holdersByKey, + scaledByIdentity, + keyIndex); if (scaledTarget != originalZone.get().otherSet()) { scaledZone = Optional.of(new StructurePlacement.ExclusionZone( scaledTarget, originalZone.get().chunkCount())); } } - double multiplier = importedStructures.frequencyMultiplier(structureSetKey(holder)); + double multiplier = importedStructures.frequencyMultiplier( + structureSetKey(holder, keyIndex)); StructurePlacement scaledPlacement = multiplier == 1D && scaledZone.equals(originalZone) ? originalPlacement : copyPlacement(originalPlacement, scaledZone, multiplier); @@ -287,12 +326,14 @@ final class DatapackStructureStateFilter { private static boolean dependsOnFrequencyOverride( Holder holder, IrisImportedStructureControl importedStructures, - Map> holdersByKey + Map> holdersByKey, + StructureSetKeyIndex keyIndex ) { Set> visited = Collections.newSetFromMap(new IdentityHashMap<>()); Holder current = holder; while (visited.add(current)) { - if (importedStructures.frequencyMultiplier(structureSetKey(current)) != 1D) { + if (importedStructures.frequencyMultiplier( + structureSetKey(current, keyIndex)) != 1D) { return true; } Optional zone = @@ -300,24 +341,26 @@ final class DatapackStructureStateFilter { if (zone.isEmpty()) { return false; } - current = canonicalHolder(zone.get().otherSet(), holdersByKey); + current = canonicalHolder(zone.get().otherSet(), holdersByKey, keyIndex); } return false; } private static Holder canonicalHolder( Holder holder, - Map> holdersByKey + Map> holdersByKey, + StructureSetKeyIndex keyIndex ) { - String key = structureSetKey(holder); + String key = structureSetKey(holder, keyIndex); return key == null ? holder : holdersByKey.getOrDefault(key, holder); } private static Holder replacementHolder( Holder original, - StructureSet replacement + StructureSet replacement, + StructureSetKeyIndex keyIndex ) { - Optional> key = structureSetResourceKey(original); + Optional> key = keyIndex.resolve(original); if (key.isEmpty()) { return Holder.direct(replacement); } @@ -326,20 +369,6 @@ final class DatapackStructureStateFilter { return holder; } - private static Optional> structureSetResourceKey( - Holder holder - ) { - Optional> key = holder.unwrapKey(); - if (key.isPresent()) { - return key; - } - if (holder.value().placement() - instanceof ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement) { - return Optional.of(keyedPlacement.key); - } - return Optional.empty(); - } - private static StructurePlacement copyPlacement( StructurePlacement placement, Optional exclusionZone, @@ -357,26 +386,7 @@ final class DatapackStructureStateFilter { int salt = (int) declaredFieldValue( StructurePlacement.class, placement, int.class); - if (placement.getClass() - == ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement.class) { - ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement = - (ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement) placement; - NativeStructureFrequencyScale scale = NativeStructureFrequencyScale.randomSpread( - frequency, keyedPlacement.spacing(), keyedPlacement.separation(), frequencyMultiplier); - return new ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement( - keyedPlacement.key, - locateOffset, - frequencyReductionMethod, - scale.frequency(), - salt, - exclusionZone, - scale.spacing(), - keyedPlacement.separation(), - keyedPlacement.spreadType()); - } - if (placement.getClass() == RandomSpreadStructurePlacement.class) { - RandomSpreadStructurePlacement randomSpread = - (RandomSpreadStructurePlacement) placement; + if (placement instanceof RandomSpreadStructurePlacement randomSpread) { NativeStructureFrequencyScale scale = NativeStructureFrequencyScale.randomSpread( frequency, randomSpread.spacing(), randomSpread.separation(), frequencyMultiplier); return new RandomSpreadStructurePlacement( @@ -439,6 +449,46 @@ final class DatapackStructureStateFilter { } } + static final class StructureSetKeyIndex { + private final Map, ResourceKey> + keysByEntries; + + private StructureSetKeyIndex( + Map, ResourceKey> + keysByEntries + ) { + this.keysByEntries = keysByEntries; + } + + private static StructureSetKeyIndex create( + Iterable> registeredStructureSets + ) { + Map, ResourceKey> keys = + new IdentityHashMap<>(); + for (Holder holder : registeredStructureSets) { + Optional> key = holder.unwrapKey(); + if (key.isEmpty()) { + continue; + } + List entries = holder.value().structures(); + ResourceKey previous = keys.put(entries, key.get()); + if (previous != null && !previous.equals(key.get())) { + throw new IllegalStateException( + "Multiple registered structure sets share the same entry list"); + } + } + return new StructureSetKeyIndex(keys); + } + + private Optional> resolve(Holder holder) { + Optional> key = holder.unwrapKey(); + if (key.isPresent()) { + return key; + } + return Optional.ofNullable(keysByEntries.get(holder.value().structures())); + } + } + record Selection( List> structureSets, int retainedManagedSets, diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index d7315d8cb..cdf42970c 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -15,7 +15,6 @@ import art.arcane.iris.core.nms.container.BiomeColor; import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.core.nms.container.BlockProperty; import art.arcane.iris.core.nms.datapack.DataVersion; -import art.arcane.iris.core.lifecycle.WorldReplacementSeed; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; @@ -55,8 +54,6 @@ import art.arcane.volmlib.util.nbt.tag.CompoundTag; import art.arcane.iris.util.common.scheduling.J; import it.unimi.dsi.fastutil.objects.Object2IntMap; import it.unimi.dsi.fastutil.shorts.ShortList; -import io.papermc.paper.world.saveddata.PaperLevelOverrides; -import io.papermc.paper.world.saveddata.PaperWorldMetadata; import net.bytebuddy.ByteBuddy; import net.bytebuddy.agent.builder.AgentBuilder; import net.bytebuddy.agent.builder.ResettableClassFileTransformer; @@ -119,14 +116,14 @@ import net.minecraft.world.level.levelgen.XoroshiroRandomSource; 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.StructureSet; import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure; -import net.minecraft.world.level.storage.PrimaryLevelData; -import net.minecraft.world.level.storage.SavedDataStorage; import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature; import net.minecraft.world.level.levelgen.feature.ConfiguredFeature; import net.minecraft.world.level.levelgen.feature.FallenTreeFeature; import net.minecraft.world.level.levelgen.feature.Feature; import net.minecraft.world.level.levelgen.feature.TreeFeature; +import net.minecraft.world.level.storage.LevelStorageSource; import net.minecraft.world.level.levelgen.flat.FlatLayerInfo; import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings; import org.bukkit.Bukkit; @@ -162,8 +159,6 @@ import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; -import java.nio.file.Files; -import java.nio.file.LinkOption; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; @@ -173,23 +168,18 @@ import java.util.Map; import java.util.Optional; import java.util.List; import java.util.Set; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; public class NMSBinding implements INMSBinding { - private static final long CURRENT_WORLD_DATA_SNAPSHOT_TIMEOUT_SECONDS = 30L; - private final KMap baseBiomeCache = new KMap<>(); private volatile DataVersion dataVersion; private final BlockData AIR = Material.AIR.createBlockData(); private final AtomicCache> biomeMapCache = new AtomicCache<>(); private final AtomicBoolean injected = new AtomicBoolean(); + private volatile ResettableClassFileTransformer levelStorageAccessTransformer; private volatile ResettableClassFileTransformer serverLevelTransformer; private final AtomicCache> registryCache = new AtomicCache<>(); private final AtomicCache> globalCache = new AtomicCache<>(); @@ -1249,8 +1239,15 @@ public class NMSBinding implements INMSBinding { ChunkGeneratorStructureState currentState = level.getChunkSource().getGeneratorState(); net.minecraft.world.level.chunk.ChunkGenerator generator = level.getChunkSource().getGenerator(); ChunkGeneratorStructureState scopedState = createStructureState(level, generator, currentState); + Registry structureSetRegistry = + level.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET); DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter( - scopedState.possibleStructureSets(), scopeIndex, declaredSources, importedStructures); + scopedState.possibleStructureSets(), + scopeIndex, + declaredSources, + importedStructures, + DatapackStructureStateFilter.keyIndex( + structureSetRegistry.listElements().toList())); Field possibleSetsField = getField(scopedState.getClass(), List.class); possibleSetsField.setAccessible(true); @@ -1328,7 +1325,7 @@ public class NMSBinding implements INMSBinding { level.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET), currentState.randomState(), currentState.getLevelSeed(), - currentState.conf); + level.spigotConfig); } private void initializeAndPublishStructureState( @@ -1542,6 +1539,18 @@ public class NMSBinding implements INMSBinding { } try { IrisLogging.info("Injecting Bukkit"); + levelStorageAccessTransformer = new AgentBuilder.Default() + .disableClassFormatChanges() + .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) + .type(ElementMatchers.is(LevelStorageSource.LevelStorageAccess.class)) + .transform((builder, typeDescription, classLoader, module, protectionDomain) -> + builder.visit(Advice.to(LevelStorageAccessAdvice.class).on(ElementMatchers.isConstructor() + .and(ElementMatchers.takesArguments(4)) + .and(ElementMatchers.takesArgument(0, LevelStorageSource.class)) + .and(ElementMatchers.takesArgument(1, String.class)) + .and(ElementMatchers.takesArgument(2, Path.class)) + .and(ElementMatchers.takesArgument(3, ResourceKey.class))))) + .installOn(Agent.getInstrumentation()); serverLevelTransformer = new AgentBuilder.Default() .disableClassFormatChanges() .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) @@ -1549,8 +1558,7 @@ public class NMSBinding implements INMSBinding { .transform((builder, typeDescription, classLoader, module, protectionDomain) -> builder.visit(Advice.to(ServerLevelAdvice.class).on(ElementMatchers.isConstructor() .and(ElementMatchers.takesArgument(0, MinecraftServer.class)) - .and(ElementMatchers.takesArgument(5, LevelStem.class)) - .and(ElementMatchers.takesArgument(12, ChunkGenerator.class))))) + .and(ElementMatchers.takesArgument(5, LevelStem.class))))) .installOn(Agent.getInstrumentation()); ByteBuddy buddy = new ByteBuddy(); for (Class clazz : List.of(ChunkAccess.class, ProtoChunk.class)) { @@ -1565,12 +1573,17 @@ public class NMSBinding implements INMSBinding { } catch (Throwable e) { IrisLogging.error(C.RED + "Failed to inject Bukkit"); e.printStackTrace(); - // The ServerLevel transformer may already be installed when the ChunkAccess - // redefine throws; remove it or a retry would stack a second one and orphan - // this one permanently. - ResettableClassFileTransformer partial = serverLevelTransformer; + ResettableClassFileTransformer partialServerLevel = serverLevelTransformer; + ResettableClassFileTransformer partialStorageAccess = levelStorageAccessTransformer; serverLevelTransformer = null; - if (partial != null) { + levelStorageAccessTransformer = null; + for (ResettableClassFileTransformer partial : new ResettableClassFileTransformer[]{ + partialServerLevel, + partialStorageAccess + }) { + if (partial == null) { + continue; + } try { partial.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION); } catch (Throwable ignored) { @@ -1586,26 +1599,36 @@ public class NMSBinding implements INMSBinding { if (!injected.get()) return; try { - Agent.getInstrumentation().retransformClasses(ServerLevel.class); + Agent.getInstrumentation().retransformClasses( + LevelStorageSource.LevelStorageAccess.class, + ServerLevel.class + ); } catch (Throwable e) { - IrisLogging.error(C.RED + "Failed to re-apply ServerLevel injection"); + IrisLogging.error(C.RED + "Failed to re-apply Bukkit world lifecycle injection"); } } @Override public void uninjectBukkit() { synchronized (injected) { - ResettableClassFileTransformer transformer = serverLevelTransformer; + ResettableClassFileTransformer activeServerLevel = serverLevelTransformer; + ResettableClassFileTransformer activeStorageAccess = levelStorageAccessTransformer; serverLevelTransformer = null; + levelStorageAccessTransformer = null; injected.set(false); - if (transformer == null) { - return; - } - try { - transformer.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION); - } catch (Throwable e) { - IrisLogging.error(C.RED + "Failed to remove ServerLevel injection"); - e.printStackTrace(); + for (ResettableClassFileTransformer transformer : new ResettableClassFileTransformer[]{ + activeServerLevel, + activeStorageAccess + }) { + if (transformer == null) { + continue; + } + try { + transformer.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION); + } catch (Throwable e) { + IrisLogging.error(C.RED + "Failed to remove Bukkit world lifecycle injection"); + e.printStackTrace(); + } } } } @@ -1616,103 +1639,7 @@ public class NMSBinding implements INMSBinding { Path targetWorldDirectory, long seed ) throws IOException { - CraftServer craftServer = (CraftServer) Bukkit.getServer(); - MinecraftServer server = craftServer.getHandle().getServer(); - PaperLevelOverrides levelOverrides = captureCurrentPaperLevelOverrides(craftServer, server); - - Path targetWorld = targetWorldDirectory.toAbsolutePath().normalize(); - UUID metadataUuid = UUID.randomUUID(); - WorldReplacementSeed.copyWithAuthoritativeSeed(sourceWorldDirectory, targetWorld, seed); - try (SavedDataStorage savedDataStorage = new SavedDataStorage( - targetWorld.resolve("data"), - server.getFixerUpper(), - server.registryAccess() - )) { - savedDataStorage.set(PaperWorldMetadata.TYPE, new PaperWorldMetadata(metadataUuid)); - savedDataStorage.set( - PaperLevelOverrides.TYPE, - levelOverrides - ); - } - - List requiredDataFiles = List.of( - targetWorld.resolve("data/minecraft/world_gen_settings.dat"), - targetWorld.resolve("data/paper/metadata.dat"), - targetWorld.resolve("data/paper/level_overrides.dat") - ); - for (Path requiredDataFile : requiredDataFiles) { - if (!Files.isRegularFile(requiredDataFile, LinkOption.NOFOLLOW_LINKS)) { - throw new IOException("Current Paper world data was not written: " + requiredDataFile); - } - } - - long writtenSeed = WorldReplacementSeed.readAuthoritativeSeed(targetWorld); - if (writtenSeed != seed) { - throw new IOException("Current Paper world data did not retain the requested seed."); - } - - try (SavedDataStorage verificationStorage = new SavedDataStorage( - targetWorld.resolve("data"), - server.getFixerUpper(), - server.registryAccess() - )) { - PaperWorldMetadata metadata = verificationStorage.get(PaperWorldMetadata.TYPE); - if (metadata == null || !metadataUuid.equals(metadata.uuid())) { - throw new IOException("Current Paper world metadata could not be verified."); - } - PaperLevelOverrides overrides = verificationStorage.get(PaperLevelOverrides.TYPE); - if (overrides == null || overrides.isInitialized()) { - throw new IOException("Current Paper level overrides could not be verified."); - } - } - } - - private PaperLevelOverrides captureCurrentPaperLevelOverrides( - CraftServer craftServer, - MinecraftServer server - ) throws IOException { - if (craftServer.isGlobalTickThread()) { - return createCurrentPaperLevelOverrides(craftServer, server); - } - if (J.isFolia() && J.isPrimaryThread()) { - throw new IOException("Current Paper world data cannot be staged from a Folia region tick thread."); - } - - CompletableFuture captured = new CompletableFuture<>(); - boolean scheduled = J.runGlobal(() -> { - try { - captured.complete(createCurrentPaperLevelOverrides(craftServer, server)); - } catch (Throwable failure) { - captured.completeExceptionally(failure); - } - }); - if (!scheduled) { - throw new IOException("Could not schedule the current Paper level-data snapshot on the global thread."); - } - - try { - return captured.get(CURRENT_WORLD_DATA_SNAPSHOT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } catch (InterruptedException failure) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted while capturing current Paper level data.", failure); - } catch (ExecutionException failure) { - throw new IOException("Could not capture current Paper level data.", failure.getCause()); - } catch (TimeoutException failure) { - throw new IOException("Timed out while capturing current Paper level data.", failure); - } - } - - private PaperLevelOverrides createCurrentPaperLevelOverrides( - CraftServer craftServer, - MinecraftServer server - ) throws IOException { - if (!craftServer.isGlobalTickThread()) { - throw new IOException("Current Paper level data must be captured on the global tick thread."); - } - if (!(server.getWorldData().overworldData() instanceof PrimaryLevelData primaryLevelData)) { - throw new IOException("Paper primary level data is unavailable for current world data staging."); - } - return PaperLevelOverrides.createFromLiveLevelData(primaryLevelData); + CurrentPaperWorldDataWriter.write(sourceWorldDirectory, targetWorldDirectory, seed); } @Override @@ -1777,13 +1704,76 @@ public class NMSBinding implements INMSBinding { } } + private static class LevelStorageAccessAdvice { + @Advice.OnMethodEnter + static void enter( + @Advice.Argument(1) String levelId, + @Advice.Argument(value = 3, readOnly = false) ResourceKey dimensionType + ) { + if (levelId == null || levelId.isBlank()) { + return; + } + + ClassLoader pluginClassLoader; + Class generatorType; + Class stagingType; + try { + org.bukkit.plugin.Plugin irisPlugin = Bukkit.getPluginManager().getPlugin("Iris"); + if (irisPlugin == null) { + return; + } + pluginClassLoader = irisPlugin.getClass().getClassLoader(); + generatorType = Class.forName("art.arcane.iris.engine.platform.PlatformChunkGenerator", true, pluginClassLoader); + stagingType = Class.forName("art.arcane.iris.core.lifecycle.WorldLifecycleStaging", true, pluginClassLoader); + } catch (Throwable ignored) { + return; + } + + Object generator; + try { + generator = stagingType + .getDeclaredMethod("peekStemGenerator", String.class) + .invoke(null, levelId); + } catch (Throwable e) { + throw new RuntimeException("Iris failed to inspect the staged world generator", + e instanceof InvocationTargetException ex ? ex.getCause() : e); + } + if (generator == null || !generatorType.isInstance(generator)) { + return; + } + + try { + Object target = generatorType.getMethod("getTarget").invoke(generator); + if (target == null) { + throw new IllegalStateException("Iris generator has no engine target."); + } + Object world = target.getClass().getMethod("getWorld").invoke(target); + if (world == null) { + throw new IllegalStateException("Iris generator target has no world identity."); + } + Object rawIdentity = world.getClass().getMethod("identity").invoke(world); + String worldIdentity = rawIdentity == null ? "" : rawIdentity.toString().trim(); + Identifier worldIdentifier = Identifier.parse(worldIdentity); + if (!"iris".equals(worldIdentifier.getNamespace()) + && !"minecraft".equals(worldIdentifier.getNamespace())) { + throw new IllegalStateException( + "Iris generator has an unmanaged world identity: " + worldIdentity); + } + dimensionType = ResourceKey.create(Registries.LEVEL_STEM, worldIdentifier); + } catch (Throwable e) { + throw new RuntimeException("Iris failed to bind the staged world storage identity", + e instanceof InvocationTargetException ex ? ex.getCause() : e); + } + } + } + private static class ServerLevelAdvice { @Advice.OnMethodEnter static void enter( @Advice.Argument(0) MinecraftServer server, - @Advice.Argument(4) ResourceKey dimensionKey, + @Advice.Argument(value = 4, readOnly = false) ResourceKey dimensionKey, @Advice.Argument(value = 5, readOnly = false) LevelStem levelStem, - @Advice.Argument(12) ChunkGenerator constructorGenerator + @Advice.AllArguments Object[] constructorArguments ) { if (dimensionKey == null) return; @@ -1818,6 +1808,13 @@ public class NMSBinding implements INMSBinding { // the vanilla stem corrupts generation. ChunkGenerator gen = null; try { + ChunkGenerator constructorGenerator = null; + for (Object argument : constructorArguments) { + if (argument instanceof ChunkGenerator candidate) { + constructorGenerator = candidate; + break; + } + } Object generator = generatorType.isInstance(constructorGenerator) ? constructorGenerator : null; if (generator == null) { generator = stagingType @@ -1855,10 +1852,27 @@ public class NMSBinding implements INMSBinding { if (stemMethod == null) { throw new IllegalStateException("Iris binding is missing createRuntimeLevelStem."); } + Object target = generatorType.getMethod("getTarget").invoke(gen); + if (target == null) { + throw new IllegalStateException("Iris generator has no engine target."); + } + Object world = target.getClass().getMethod("getWorld").invoke(target); + if (world == null) { + throw new IllegalStateException("Iris generator target has no world identity."); + } + Object rawIdentity = world.getClass().getMethod("identity").invoke(world); + String worldIdentity = rawIdentity == null ? "" : rawIdentity.toString().trim(); + Identifier worldIdentifier = Identifier.parse(worldIdentity); + if (!"iris".equals(worldIdentifier.getNamespace()) + && !"minecraft".equals(worldIdentifier.getNamespace())) { + throw new IllegalStateException( + "Iris generator has an unmanaged world identity: " + worldIdentity); + } Object resolvedStem = stemMethod.invoke(bindings, server.registryAccess(), gen); if (!(resolvedStem instanceof LevelStem runtimeStem)) { throw new IllegalStateException("Iris runtime LevelStem binding returned " + (resolvedStem == null ? "null" : resolvedStem.getClass().getName()) + "."); } + dimensionKey = ResourceKey.create(Registries.DIMENSION, worldIdentifier); levelStem = runtimeStem; } catch (Throwable e) { throw new RuntimeException("Iris failed to replace the levelStem", e instanceof InvocationTargetException ex ? ex.getCause() : e); diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java index 2260e1600..cc4ff499f 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccess.java @@ -215,6 +215,14 @@ final class NativeStructureWorldgenAccess implements WorldGenLevel { } } + @Override + public ChunkAccess getChunk(int chunkX, int chunkZ) { + if (isInsideGenerationRegion(chunkX, chunkZ)) { + return delegate.getChunk(chunkX, chunkZ); + } + return outsideChunk(chunkX, chunkZ); + } + @Override public ChunkAccess getChunk(int chunkX, int chunkZ, ChunkStatus status, boolean create) { if (isInsideGenerationRegion(chunkX, chunkZ)) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingCurrentPaperWorldDataContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingCurrentPaperWorldDataContractTest.java index 09dfdbec6..396124c17 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingCurrentPaperWorldDataContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingCurrentPaperWorldDataContractTest.java @@ -3,29 +3,54 @@ package art.arcane.iris.core.nms.v26_2_R1; import art.arcane.iris.core.nms.INMSBinding; import org.junit.Test; +import java.io.InputStream; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class NMSBindingCurrentPaperWorldDataContractTest { @Test - public void stagesAllCurrentPaperWorldDataFromLiveServerState() throws Exception { - String source = Files.readString(Path.of(System.getProperty("iris.nmsBindingSource"))); + public void bindingDelegatesWithoutLinkingPaperSavedDataClasses() throws Exception { + String bindingSource = Files.readString(bindingSourcePath()); String writer = section( - source, + bindingSource, "public void writeCurrentPaperWorldData(", - "public KMap> getBlockProperties()" + "public boolean awaitServerShutdownBoundary(" ); + assertTrue(writer.contains("CurrentPaperWorldDataWriter.write(")); + assertFalse(bindingSource.contains("PaperWorldMetadata")); + assertFalse(bindingSource.contains("PaperLevelOverrides")); + assertFalse(bindingSource.contains("io.papermc.paper.world.saveddata")); + + InputStream classResource = NMSBindingCurrentPaperWorldDataContractTest.class + .getResourceAsStream("NMSBinding.class"); + assertNotNull(classResource); + try (InputStream input = classResource) { + String classFile = new String(input.readAllBytes(), StandardCharsets.ISO_8859_1); + assertFalse(classFile.contains("PaperWorldMetadata")); + assertFalse(classFile.contains("PaperLevelOverrides")); + assertFalse(classFile.contains("io/papermc/paper/world/saveddata")); + } + } + + @Test + public void stagesAllCurrentPaperWorldDataFromLiveServerState() throws Exception { + String writer = Files.readString(writerSourcePath()); + assertTrue(writer.contains("WorldReplacementSeed.copyWithAuthoritativeSeed(")); assertTrue(writer.contains("UUID metadataUuid = UUID.randomUUID()")); assertTrue(writer.contains("new PaperWorldMetadata(metadataUuid)")); - assertTrue(writer.contains("captureCurrentPaperLevelOverrides(craftServer, server)")); + assertTrue(writer.contains("captureLevelOverrides(craftServer, server)")); + assertTrue(writer.indexOf("captureLevelOverrides(craftServer, server)") + < writer.indexOf("WorldReplacementSeed.copyWithAuthoritativeSeed(")); assertTrue(writer.contains("new SavedDataStorage(")); assertTrue(writer.contains("server.getFixerUpper()")); assertTrue(writer.contains("server.registryAccess()")); @@ -44,23 +69,23 @@ public class NMSBindingCurrentPaperWorldDataContractTest { @Test public void capturesOnlyLiveLevelOverridesOnTheGlobalThread() throws Exception { - String source = Files.readString(Path.of(System.getProperty("iris.nmsBindingSource"))); + String source = Files.readString(writerSourcePath()); String capture = section( source, - "private PaperLevelOverrides captureCurrentPaperLevelOverrides(", - "private PaperLevelOverrides createCurrentPaperLevelOverrides(" + "private static PaperLevelOverrides captureLevelOverrides(", + "private static PaperLevelOverrides createLevelOverrides(" ); String create = section( source, - "private PaperLevelOverrides createCurrentPaperLevelOverrides(", - "public KMap> getBlockProperties()" + "private static PaperLevelOverrides createLevelOverrides(", + "\n }\n}" ); assertTrue(capture.contains("craftServer.isGlobalTickThread()")); assertTrue(capture.contains("J.isFolia() && J.isPrimaryThread()")); assertTrue(capture.contains("J.runGlobal(")); - assertTrue(capture.contains("createCurrentPaperLevelOverrides(craftServer, server)")); - assertTrue(capture.contains("captured.get(CURRENT_WORLD_DATA_SNAPSHOT_TIMEOUT_SECONDS")); + assertTrue(capture.contains("createLevelOverrides(craftServer, server)")); + assertTrue(capture.contains("captured.get(SNAPSHOT_TIMEOUT_SECONDS")); assertTrue(capture.contains("Thread.currentThread().interrupt()")); assertTrue(create.contains("if (!craftServer.isGlobalTickThread())")); assertTrue(create.indexOf("if (!craftServer.isGlobalTickThread())") @@ -89,6 +114,14 @@ public class NMSBindingCurrentPaperWorldDataContractTest { assertTrue(error.getMessage().contains("does not support current Paper world data staging")); } + private static Path bindingSourcePath() { + return Path.of(System.getProperty("iris.nmsBindingSource")); + } + + private static Path writerSourcePath() { + return bindingSourcePath().resolveSibling("CurrentPaperWorldDataWriter.java"); + } + private static String section(String source, String startMarker, String endMarker) { int start = source.indexOf(startMarker); int end = source.indexOf(endMarker, start); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java index 84afe6357..401f1219e 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingDatapackStructureScopeTest.java @@ -27,6 +27,8 @@ import org.junit.Test; import org.junit.BeforeClass; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -38,8 +40,8 @@ import java.util.stream.Stream; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class NMSBindingDatapackStructureScopeTest { @@ -108,28 +110,56 @@ public class NMSBindingDatapackStructureScopeTest { } @Test - public void spigotDirectHolderRetainsItsStructureSetKey() { + public void spigotDirectHolderRecoversItsRegistryKeyFromSharedEntries() { ResourceKey key = ResourceKey.create( Registries.STRUCTURE_SET, - Identifier.parse("minecraft:villages")); - ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement placement = - new ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement( - key, - Vec3i.ZERO, - StructurePlacement.FrequencyReductionMethod.DEFAULT, - 1.0F, - 10387312, - Optional.empty(), - 34, - 8, - RandomSpreadType.LINEAR); - StructureSet structureSet = new StructureSet( - List.of(new StructureSet.StructureSelectionEntry( - structureHolder("minecraft:village_plains"), 1)), - placement); + Identifier.parse("managed:illager_barracks")); + List entries = List.of( + new StructureSet.StructureSelectionEntry( + structureHolder("minecraft:pillager_outpost"), 1)); + Holder registered = new KeyedHolder<>(key, new StructureSet( + entries, + new RandomSpreadStructurePlacement( + 34, 8, RandomSpreadType.LINEAR, 10387312))); + Holder spigotDirect = Holder.direct(new StructureSet( + entries, + new RandomSpreadStructurePlacement( + 34, 8, RandomSpreadType.LINEAR, 14357620))); + DatapackStructureScopeIndex scopeIndex = index( + List.of(), List.of("managed:illager_barracks")); + DatapackStructureStateFilter.StructureSetKeyIndex keyIndex = + DatapackStructureStateFilter.keyIndex(List.of(registered)); - assertEquals("minecraft:villages", - DatapackStructureStateFilter.structureSetKey(Holder.direct(structureSet))); + DatapackStructureStateFilter.Selection excluded = + DatapackStructureStateFilter.filter( + List.of(spigotDirect), + scopeIndex, + Set.of(), + new IrisImportedStructureControl(), + keyIndex); + DatapackStructureStateFilter.Selection retained = + DatapackStructureStateFilter.filter( + List.of(spigotDirect), + scopeIndex, + scopeIndex.declaredSources(List.of(SOURCE)), + new IrisImportedStructureControl(), + keyIndex); + + assertEquals(0, excluded.structureSets().size()); + assertEquals(1, excluded.excludedManagedSets()); + assertSame(spigotDirect, retained.structureSets().getFirst()); + assertEquals(1, retained.retainedManagedSets()); + } + + @Test + public void structureScopeDoesNotLinkPaperOnlyPlacementClasses() throws IOException { + InputStream classResource = NMSBindingDatapackStructureScopeTest.class + .getResourceAsStream("DatapackStructureStateFilter.class"); + assertNotNull(classResource); + try (InputStream input = classResource) { + String classFile = new String(input.readAllBytes(), StandardCharsets.ISO_8859_1); + assertFalse(classFile.contains("KeyedRandomSpreadStructurePlacement")); + } } @Test @@ -271,7 +301,7 @@ public class NMSBindingDatapackStructureScopeTest { } @Test - public void affectedRandomSpreadSubclassFailsInsteadOfLosingSubtypeBehavior() { + public void randomSpreadSubtypeUsesTheCanonicalPlacementContract() { Holder custom = structureSetHolder( "example:custom", new CustomRandomSpreadPlacement(), @@ -283,8 +313,12 @@ public class NMSBindingDatapackStructureScopeTest { IrisImportedStructureControl control = new IrisImportedStructureControl() .setFrequencyOverrides(overrides); - assertThrows(IllegalStateException.class, () -> DatapackStructureStateFilter.filter( - List.of(custom), index(List.of(), List.of()), Set.of(), control)); + DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter( + List.of(custom), index(List.of(), List.of()), Set.of(), control); + StructurePlacement placement = selection.structureSets().getFirst().value().placement(); + + assertEquals(RandomSpreadStructurePlacement.class, placement.getClass()); + assertEquals(26, ((RandomSpreadStructurePlacement) placement).spacing()); } @Test @@ -395,6 +429,21 @@ public class NMSBindingDatapackStructureScopeTest { assertTrue(structureRetarget > identityGate); } + @Test + public void structureStateRecreationUsesTheWorldOwnedSpigotConfiguration() throws IOException { + Path chunkGeneratorSource = Path.of(System.getProperty("iris.nmsChunkGeneratorSource")); + String source = Files.readString(chunkGeneratorSource.resolveSibling("NMSBinding.java")); + int methodStart = source.indexOf("private ChunkGeneratorStructureState createStructureState("); + int methodEnd = source.indexOf( + "\n private void initializeAndPublishStructureState(", methodStart); + + assertTrue(methodStart >= 0); + assertTrue(methodEnd > methodStart); + String method = source.substring(methodStart, methodEnd); + assertTrue(method.contains("level.spigotConfig")); + assertFalse(method.contains("currentState.conf")); + } + private static DatapackStructureScopeIndex index( List structureKeys, List structureSetKeys diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingServerLevelAdviceContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingServerLevelAdviceContractTest.java new file mode 100644 index 000000000..a63374c1e --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NMSBindingServerLevelAdviceContractTest.java @@ -0,0 +1,103 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class NMSBindingServerLevelAdviceContractTest { + @Test + public void serverLevelAdviceSupportsCurrentSpigotAndPaperConstructorLayouts() throws Exception { + String source = Files.readString( + Path.of(System.getProperty("iris.nmsBindingSource"))); + String transformer = section( + source, + "public boolean injectBukkit()", + "public void ensureServerLevelInjection()"); + String advice = section( + source, + "private static class ServerLevelAdvice", + "\n }\n}"); + + assertTrue(transformer.contains("takesArgument(0, MinecraftServer.class)")); + assertTrue(transformer.contains("takesArgument(5, LevelStem.class)")); + assertFalse(transformer.contains("takesArgument(12, ChunkGenerator.class)")); + assertFalse(transformer.contains("takesArgument(13, ChunkGenerator.class)")); + assertTrue(advice.contains("@Advice.AllArguments Object[] constructorArguments")); + assertTrue(advice.contains("argument instanceof ChunkGenerator candidate")); + assertFalse(advice.contains("@Advice.Argument(12)")); + assertFalse(advice.contains("@Advice.Argument(13)")); + } + + @Test + public void currentSpigotStorageAccessReceivesManagedIdentityBeforeConstruction() throws Exception { + String source = Files.readString( + Path.of(System.getProperty("iris.nmsBindingSource"))); + String transformer = section( + source, + "public boolean injectBukkit()", + "public void ensureServerLevelInjection()"); + String advice = section( + source, + "private static class LevelStorageAccessAdvice", + "private static class ServerLevelAdvice"); + + assertTrue(transformer.contains( + ".type(ElementMatchers.is(LevelStorageSource.LevelStorageAccess.class))")); + assertTrue(transformer.contains("Advice.to(LevelStorageAccessAdvice.class)")); + assertTrue(transformer.contains("ElementMatchers.takesArguments(4)")); + assertTrue(transformer.contains( + "ElementMatchers.takesArgument(0, LevelStorageSource.class)")); + assertTrue(transformer.contains("ElementMatchers.takesArgument(1, String.class)")); + assertTrue(transformer.contains("ElementMatchers.takesArgument(2, Path.class)")); + assertTrue(transformer.contains("ElementMatchers.takesArgument(3, ResourceKey.class)")); + assertTrue(advice.contains("@Advice.Argument(1) String levelId")); + assertTrue(advice.contains( + "@Advice.Argument(value = 3, readOnly = false) ResourceKey dimensionType")); + assertTrue(advice.contains("getDeclaredMethod(\"peekStemGenerator\", String.class)")); + assertTrue(advice.contains(".invoke(null, levelId)")); + assertTrue(advice.contains( + "dimensionType = ResourceKey.create(Registries.LEVEL_STEM, worldIdentifier);")); + assertFalse(advice.contains("dimensionRoot(")); + assertFalse(advice.contains("Files.copy")); + } + + @Test + public void ownedWorldPublishesItsManagedIdentityAndRuntimeStemTogether() throws Exception { + String source = Files.readString( + Path.of(System.getProperty("iris.nmsBindingSource"))); + String advice = section( + source, + "private static class ServerLevelAdvice", + "\n }\n}"); + + assertTrue(advice.contains( + "@Advice.Argument(value = 4, readOnly = false) ResourceKey dimensionKey")); + assertTrue(advice.contains("getMethod(\"getTarget\")")); + assertTrue(advice.contains("getMethod(\"getWorld\")")); + assertTrue(advice.contains("getMethod(\"identity\")")); + assertTrue(advice.contains("\"iris\".equals(worldIdentifier.getNamespace())")); + assertTrue(advice.contains("\"minecraft\".equals(worldIdentifier.getNamespace())")); + + int resolvedStem = advice.indexOf("Object resolvedStem = stemMethod.invoke("); + int validatedStem = advice.indexOf("resolvedStem instanceof LevelStem runtimeStem"); + int identityPublication = advice.indexOf( + "dimensionKey = ResourceKey.create(Registries.DIMENSION, worldIdentifier);"); + int stemPublication = advice.indexOf("levelStem = runtimeStem;"); + assertTrue(resolvedStem >= 0); + assertTrue(validatedStem > resolvedStem); + assertTrue(identityPublication > validatedStem); + assertTrue(stemPublication > identityPublication); + } + + private static String section(String source, String startMarker, String endMarker) { + int start = source.indexOf(startMarker); + int end = source.indexOf(endMarker, start); + assertTrue("Missing source section starting with " + startMarker, start >= 0); + assertTrue("Missing source section ending with " + endMarker, end > start); + return source.substring(start, end); + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java index 6a9ddc44a..8a9cc68c4 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/NativeStructureWorldgenAccessTest.java @@ -135,6 +135,18 @@ public class NativeStructureWorldgenAccessTest { assertEquals(1, recording.blockTicks.count()); } + @Test + public void statuslessChunkReadsPreserveWorldgenDelegateSemantics() { + RecordingDelegate recording = new RecordingDelegate(); + NativeStructureWorldgenAccess access = access(recording); + ChunkPos generationCenter = generationCenter(); + + assertNull(access.getChunk(generationCenter.getWorldPosition())); + + assertEquals(1, recording.statuslessChunkReads); + assertEquals(0, recording.statusChunkReads); + } + @Test public void entityQueriesRejectDisjointAreasAndClampOverlaps() { RecordingDelegate recording = new RecordingDelegate(); @@ -187,6 +199,8 @@ public class NativeStructureWorldgenAccessTest { private int terrainReads; private int heightReads; private int chunkReads; + private int statuslessChunkReads; + private int statusChunkReads; private int mutations; private int events; @@ -240,7 +254,16 @@ public class NativeStructureWorldgenAccessTest { terrainReads++; return Blocks.WATER.defaultBlockState().getFluidState(); } - if (name.equals("getChunk") || name.equals("getChunkIfLoadedImmediately")) { + if (name.equals("getChunk")) { + chunkReads++; + if (arguments.length == 2) { + statuslessChunkReads++; + } else { + statusChunkReads++; + } + return null; + } + if (name.equals("getChunkIfLoadedImmediately")) { chunkReads++; return null; } diff --git a/adapters/bukkit/plugin/build.gradle b/adapters/bukkit/plugin/build.gradle index dffa5da6a..75104bc8c 100644 --- a/adapters/bukkit/plugin/build.gradle +++ b/adapters/bukkit/plugin/build.gradle @@ -3,7 +3,7 @@ def mainClass = 'art.arcane.iris.Iris' def bootstrapperClass = 'art.arcane.iris.IrisBootstrap' String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') - .orElse('com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969') + .orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522') .get() dependencies { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index 7778a0b19..3d66ff799 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -756,6 +756,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { IrisStartupValidation.begin(); Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this); Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this); + pendingWorldReplacements.registerPlatformEntryListener(); boolean enabled = enable(); if (!enabled) { return; diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java index 9013c541e..e49e955be 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/BukkitWorldReconciler.java @@ -22,13 +22,13 @@ import art.arcane.iris.Iris; 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.pack.PackValidationRegistry; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.platform.bukkit.BukkitEnvironment; import art.arcane.iris.util.common.format.C; import art.arcane.iris.util.common.misc.ServerProperties; import art.arcane.volmlib.util.bukkit.WorldIdentity; +import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.WorldCreator; @@ -698,16 +698,17 @@ public final class BukkitWorldReconciler { @Override public CompletableFuture 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); + String logicalWorldName = IrisWorldStorage.logicalName(worldKey); + String configuredWorldName = configuredWorldName(worldKey); + Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension); + ChunkGenerator generator = plugin.getDefaultWorldGenerator(configuredWorldName, dimension); + IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, 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) + Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + logicalWorldName + " using Iris:" + dimension + "..."); + WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey) .generator(generator) .environment(BukkitEnvironment.from(irisDimension.getEnvironment())); if (seed != null) { @@ -726,7 +727,7 @@ public final class BukkitWorldReconciler { @Override public DimensionResolution resolveDimension(NamespacedKey worldKey) { - File dimensionsDirectory = new File(IrisWorldStorage.packRoot(worldKey), "dimensions"); + File dimensionsDirectory = new File(snapshotRoot(worldKey), "dimensions"); if (!dimensionsDirectory.isDirectory()) { return DimensionResolution.failed(new IllegalStateException("The world has no Iris dimensions directory.")); } @@ -764,19 +765,30 @@ public final class BukkitWorldReconciler { @Override public void requireDimensionLoadable(NamespacedKey worldKey, String dimension) { - File snapshotRoot = IrisWorldStorage.packRoot(worldKey); - boolean snapshotPresent = snapshotRoot.isDirectory(); - if (snapshotPresent) { - IrisWorldGeneratorResolver.requireSnapshotLoadable(snapshotRoot); - } - String worldName = IrisWorldStorage.logicalName(worldKey); - IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension); + File snapshotRoot = snapshotRoot(worldKey); + IrisWorldGeneratorResolver.requireSnapshotLoadable(snapshotRoot); + String configuredWorldName = configuredWorldName(worldKey); + IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension); if (irisDimension == null) { throw new IllegalStateException("Could not resolve the Iris dimension \"" + dimension + "\"."); } - if (!snapshotPresent) { - PackValidationRegistry.requireLoadable(irisDimension.getLoader().getDataFolder().getName()); + } + + private File snapshotRoot(NamespacedKey worldKey) { + File levelRoot = IrisWorldStorage.levelRoot(); + File dimensionRoot = IrisWorldStorage.requireFrozenDimensionRoot( + Bukkit.getWorldContainer(), + levelRoot, + configuredWorldName(worldKey), + worldKey + ); + File expectedRoot = WorldCreatorCompat.persistentDimensionRoot(worldKey); + if (!dimensionRoot.toPath().toAbsolutePath().normalize() + .equals(expectedRoot.toPath().toAbsolutePath().normalize())) { + throw new IllegalStateException("Iris world storage does not match the current platform layout for " + + worldKey + "."); } + return IrisWorldStorage.requireFrozenPackRoot(dimensionRoot); } } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisStartupAdmissionListener.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisStartupAdmissionListener.java index e2c004194..3ec38ddf1 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisStartupAdmissionListener.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisStartupAdmissionListener.java @@ -1,6 +1,5 @@ package art.arcane.iris.core; -import net.kyori.adventure.text.Component; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; @@ -11,6 +10,6 @@ public final class IrisStartupAdmissionListener implements Listener { public void onAsyncPlayerPreLogin(AsyncPlayerPreLoginEvent event) { IrisStartupValidation.denialReason().ifPresent(reason -> event.disallow( AsyncPlayerPreLoginEvent.Result.KICK_OTHER, - Component.text(reason + " Check the server console, correct the reported Iris state, and restart."))); + reason + " Check the server console, correct the reported Iris state, and restart.")); } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java index 0c61ca86e..445749a8f 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java @@ -29,12 +29,12 @@ import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidator; import art.arcane.iris.spi.IrisPlatforms; -import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.util.common.plugin.VolmitPlugin; import lombok.NonNull; +import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; import org.bukkit.generator.BiomeProvider; import org.bukkit.generator.ChunkGenerator; @@ -231,9 +231,18 @@ public final class IrisWorldGeneratorResolver { @Nullable public static IrisDimension loadDimension(@NonNull String worldName, @NonNull String id) { - NamespacedKey worldKey = configuredWorldKey(worldName, IrisWorldStorage.levelRoot().getName()); - File pack = IrisWorldStorage.packRoot(worldKey); - IrisDimension dimension = pack.isDirectory() ? IrisData.get(pack).getDimensionLoader().load(id) : null; + File levelRoot = IrisWorldStorage.levelRoot(); + NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName()); + String configuredWorldName = IrisWorldStorage.configuredWorldName(worldKey, levelRoot.getName()); + File pack = IrisWorldStorage.frozenDimensionRoot( + Bukkit.getWorldContainer(), + levelRoot, + configuredWorldName, + worldKey + ) + .map(IrisWorldStorage::requireFrozenPackRoot) + .orElse(null); + IrisDimension dimension = pack == null ? null : IrisData.get(pack).getDimensionLoader().load(id); if (dimension == null) dimension = IrisData.loadAnyDimension(id, null); if (dimension == null) { File packsRoot = IrisPlatforms.get().packsFolderNoCreate(); @@ -279,57 +288,63 @@ public final class IrisWorldGeneratorResolver { if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType(); Iris.debug("Generator ID: " + id + " requested by bukkit/plugin"); - IrisDimension dim = loadDimension(worldName, id); - if (dim == null) { - throw new RuntimeException("Can't find dimension " + id + "!"); - } - NamespacedKey worldKey = configuredWorldKey(worldName, IrisWorldStorage.levelRoot().getName()); - File snapshotRoot = IrisWorldStorage.packRoot(worldKey); - File dimensionPackRoot = dim.getLoader().getDataFolder(); - String packName = dimensionPackRoot.getName(); try { - if (snapshotRoot.toPath().toAbsolutePath().normalize() - .equals(dimensionPackRoot.toPath().toAbsolutePath().normalize())) { - requireSnapshotLoadable(snapshotRoot); - } else { - PackValidationRegistry.requireLoadable(packName); - } + return resolveFrozenWorldGenerator(worldName, id); + } catch (RuntimeException failure) { + Iris.reportError("Refusing to load configured Iris world '" + worldName + + "' because its frozen world-local pack snapshot could not be used.", failure); + Bukkit.shutdown(); + throw failure; + } + } + + private ChunkGenerator resolveFrozenWorldGenerator(String worldName, String id) { + File levelRoot = IrisWorldStorage.levelRoot(); + NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName()); + File dimensionRoot = IrisWorldStorage.requireFrozenDimensionRoot( + Bukkit.getWorldContainer(), + levelRoot, + worldName, + worldKey + ); + File expectedDimensionRoot = WorldCreatorCompat.persistentDimensionRoot(worldKey); + if (!dimensionRoot.toPath().toAbsolutePath().normalize() + .equals(expectedDimensionRoot.toPath().toAbsolutePath().normalize())) { + throw new IllegalStateException("Frozen Iris world storage does not match the current platform layout for " + + worldKey + "."); + } + File snapshotRoot = IrisWorldStorage.requireFrozenPackRoot(dimensionRoot); + try { + requireSnapshotLoadable(snapshotRoot); } catch (BrokenPackException exception) { - Iris.error("Refusing to create world '" + worldName + "' using broken pack '" + packName + "':"); + Iris.error("Refusing to create world '" + worldName + "' using broken snapshot at '" + + snapshotRoot + "':"); for (String reason : exception.getReasons()) { Iris.error(" - " + reason); } throw exception; } - Iris.debug("Assuming IrisDimension: " + dim.getName()); + IrisDimension dimension = IrisData.get(snapshotRoot).getDimensionLoader().load(id, false); + if (dimension == null) { + throw new IllegalStateException("Frozen Iris pack snapshot at " + snapshotRoot + + " does not contain dimension " + id + "."); + } - IrisWorld w = IrisWorld.builder() + Iris.debug("Assuming IrisDimension: " + dimension.getName()); + + IrisWorld world = IrisWorld.builder() .platformIdentity(worldKey.toString()) .name(worldName) .seed(1337) - .worldFolder(IrisWorldStorage.dimensionRoot(worldKey)) - .minHeight(dim.getMinHeight()) - .maxHeight(dim.getMaxHeight()) + .worldFolder(dimensionRoot) + .minHeight(dimension.getMinHeight()) + .maxHeight(dimension.getMaxHeight()) .build(); - Iris.debug("Generator Config: " + w.toString()); + Iris.debug("Generator Config: " + world); - File ff = new File(w.worldFolder(), "iris/pack"); - IrisDimension installedDimension = ff.isDirectory() - ? IrisData.get(ff).getDimensionLoader().load(dim.getLoadKey(), false) - : null; - if (installedDimension == null) { - dim = Iris.service(StudioSVC.class).replaceIntoWorld(Iris.getSender(), dim, w.worldFolder()); - if (dim == null) { - throw new IllegalStateException("Failed to install dimension pack for " + id); - } - } else { - dim = installedDimension; - } - requireSnapshotLoadable(ff); - - return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey()); + return new BukkitChunkGenerator(world, false, snapshotRoot, dimension.getLoadKey()); } private record FreshValidation( diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PaperWorldReplacementEntryListener.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PaperWorldReplacementEntryListener.java new file mode 100644 index 000000000..8b25b8d70 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PaperWorldReplacementEntryListener.java @@ -0,0 +1,54 @@ +package art.arcane.iris.core; + +import io.papermc.paper.event.player.AsyncPlayerSpawnLocationEvent; +import net.kyori.adventure.text.Component; +import org.bukkit.Location; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import java.util.Objects; +import java.util.UUID; + +public final class PaperWorldReplacementEntryListener implements Listener { + private final PendingWorldReplacementManager manager; + + public PaperWorldReplacementEntryListener(PendingWorldReplacementManager manager) { + this.manager = Objects.requireNonNull(manager, "manager"); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onAsyncPlayerSpawnLocation(AsyncPlayerSpawnLocationEvent event) { + UUID playerId = event.getConnection().getProfile().getId(); + if (playerId == null) { + return; + } + try { + PendingWorldReplacementManager.ReplacementEntryRedirect redirect = manager.prepareReplacementEntry( + playerId, + event.getSpawnLocation(), + event.isNewPlayer() + ); + if (redirect == null) { + return; + } + Location location = redirect.location(); + event.setSpawnLocation(location); + if (redirect.acknowledgementRequired()) { + manager.expectReplacementEntryAcknowledgement(playerId, redirect.transactionId()); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + refuseUnsafeEntry(event, playerId, failure); + } catch (Throwable failure) { + refuseUnsafeEntry(event, playerId, failure); + } + } + + private void refuseUnsafeEntry(AsyncPlayerSpawnLocationEvent event, UUID playerId, Throwable failure) { + manager.reportUnsafeEntry(playerId, failure); + event.getConnection().disconnect(Component.text( + "Iris could not verify a safe login location after the Overworld replacement. Retry after startup completes." + )); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldDeleteQueue.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldDeleteQueue.java index 0361bf3db..8c5dac853 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldDeleteQueue.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldDeleteQueue.java @@ -451,18 +451,51 @@ public final class PendingWorldDeleteQueue implements WorldDeletionQueue { if (type == QueueEntryType.EXACT) { NamespacedKey key = IrisWorldStorage.managedKeyFromName(storedName, levelRoot.getName()); - Path path = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key).toPath(); + Path path = currentStorageRoot(levelRoot, key); return List.of(new DeleteTarget(key, path)); } ArrayList targets = new ArrayList<>(3); for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(storedName)) { NamespacedKey key = IrisWorldStorage.managedKeyFromName(familyWorldName, levelRoot.getName()); - Path path = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key).toPath(); + Path path = currentStorageRoot(levelRoot, key); targets.add(new DeleteTarget(key, path)); } return targets; } + + private static Path currentStorageRoot(File levelRoot, NamespacedKey key) { + File worldContainer = levelRoot.getAbsoluteFile().getParentFile(); + if (worldContainer == null) { + throw new IllegalArgumentException("Selected level root has no world container: " + levelRoot); + } + String configuredWorldName = IrisWorldStorage.configuredWorldName(key, levelRoot.getName()); + Path directRoot = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key) + .toPath() + .toAbsolutePath() + .normalize(); + Path dimensionRoot = IrisWorldStorage.frozenDimensionRoot( + worldContainer, + levelRoot, + configuredWorldName, + key + ).map(file -> file.toPath().toAbsolutePath().normalize()).orElse(directRoot); + if (dimensionRoot.equals(directRoot)) { + return directRoot; + } + Path configuredDimensionRoot = IrisWorldStorage.configuredDimensionRoot( + worldContainer, + levelRoot, + key + ).toPath().toAbsolutePath().normalize(); + if (!dimensionRoot.equals(configuredDimensionRoot)) { + throw new IllegalStateException("Iris world storage does not match the current platform layout."); + } + return IrisWorldStorage.configuredLevelRoot(worldContainer, levelRoot, key) + .toPath() + .toAbsolutePath() + .normalize(); + } } private static Path requireSafeQuarantinePath(File levelRoot, String quarantineName) throws IOException { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java index 41610c16e..ec7b71f94 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java @@ -8,6 +8,7 @@ import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSna import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap; import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker; +import art.arcane.iris.core.lifecycle.WorldReplacementEntryGuard; import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem; import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths; import art.arcane.iris.core.lifecycle.WorldReplacementJournal; @@ -15,24 +16,32 @@ import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase; import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction; import art.arcane.iris.core.lifecycle.WorldReplacementSeed; import art.arcane.iris.core.pack.PackValidationRegistry; +import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisEnvironment; +import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.util.common.misc.ServerProperties; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.bukkit.WorldIdentity; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Location; import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.world.WorldLoadEvent; import java.io.File; import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -40,23 +49,60 @@ import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.Optional; import java.util.OptionalLong; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; public final class PendingWorldReplacementManager implements Listener { + private static final WorldSlotKey OVERWORLD_KEY = WorldSlotKey.minecraft("overworld"); + private static final long SAFE_ENTRY_TIMEOUT_SECONDS = 30L; + private final Iris plugin; private final Set cleanupInFlight = new HashSet<>(); private final Set verificationInFlight = new HashSet<>(); + private final ConcurrentHashMap pendingEntryAcknowledgements = new ConcurrentHashMap<>(); + private volatile WorldReplacementEntryGuard.Entry overworldEntryGuard; + private volatile Path overworldEntryWorldDirectory; + private volatile CompletableFuture overworldSafeEntry; + private volatile UUID overworldEntryAwaitingVerification; + private volatile World overworldEntryWorld; + private volatile boolean paperEntryListenerRegistered; public PendingWorldReplacementManager(Iris plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); } + public void registerPlatformEntryListener() { + ClassLoader loader = getClass().getClassLoader(); + try { + Class.forName("io.papermc.paper.event.player.AsyncPlayerSpawnLocationEvent", false, loader); + } catch (ClassNotFoundException | LinkageError unavailable) { + return; + } + try { + Class listenerType = Class.forName( + "art.arcane.iris.core.PaperWorldReplacementEntryListener", + true, + loader + ); + Constructor constructor = listenerType.getConstructor(PendingWorldReplacementManager.class); + Listener listener = (Listener) constructor.newInstance(this); + Bukkit.getPluginManager().registerEvents(listener, plugin); + paperEntryListenerRegistered = true; + } catch (InvocationTargetException failure) { + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + throw new IllegalStateException("Paper replacement entry listener registration failed.", cause); + } catch (ReflectiveOperationException | LinkageError failure) { + throw new IllegalStateException("Paper replacement entry listener is unavailable.", failure); + } + } + public NamespacedKey resolveRequestedWorldKey(String requestedName) { NamespacedKey worldKey = IrisWorldStorage.replacementKeyFromName( requestedName, @@ -129,6 +175,9 @@ public final class PendingWorldReplacementManager implements Listener { paths.stage(), seedSelection ); + if (target.slotKind() == SlotKind.VANILLA_OVERWORLD) { + WorldReplacementEntryGuard.stage(target.levelRoot(), paths.stage(), transactionId); + } File stagedPack = paths.stage().resolve("iris/pack").toFile(); IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack); String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath()); @@ -205,6 +254,20 @@ public final class PendingWorldReplacementManager implements Listener { public synchronized void processPendingStartupReplacements() { ArrayList failures = new ArrayList<>(); ArrayList restartBoundaries = new ArrayList<>(); + try { + loadOverworldEntryGuard(); + } catch (Throwable failure) { + String message = "Iris could not load the Overworld replacement entry guard: " + detail(failure); + Iris.reportError(message, failure); + IrisStartupValidation.markPacksInvalid(List.of(message)); + return; + } + if (overworldEntryGuard != null && !paperEntryListenerRegistered) { + String message = "A pending Overworld replacement requires the Paper safe-entry capability."; + Iris.error(message); + IrisStartupValidation.markPacksInvalid(List.of(message)); + return; + } List transactions; try { transactions = loadTransactions(); @@ -215,6 +278,9 @@ public final class PendingWorldReplacementManager implements Listener { return; } for (Transaction transaction : transactions) { + if (OVERWORLD_KEY.equals(transaction.worldKey()) && transaction.phase() == Phase.PUBLISHED) { + overworldEntryAwaitingVerification = transaction.id(); + } try { inspectStartupTransaction(transaction); } catch (RestartBoundaryRequired boundary) { @@ -229,6 +295,7 @@ public final class PendingWorldReplacementManager implements Listener { Iris.reportError(message, failure); } } + scheduleLoadedOverworldEntryPreparation(); if (!failures.isEmpty()) { IrisStartupValidation.markPacksInvalid(failures); } @@ -258,6 +325,245 @@ public final class PendingWorldReplacementManager implements Listener { scheduleRuntimeCapture(transaction, 0); } } + scheduleLoadedOverworldEntryPreparation(); + } + + private void scheduleLoadedOverworldEntryPreparation() { + if (overworldEntryGuard == null) { + return; + } + try { + J.s(this::prepareLoadedOverworldEntry); + } catch (Throwable failure) { + failOverworldEntryPreparation(failure); + } + } + + private void prepareLoadedOverworldEntry() { + World world; + try { + world = WorldIdentity.resolve(toNamespacedKey(OVERWORLD_KEY)).orElse(null); + } catch (Throwable failure) { + failOverworldEntryPreparation(failure); + return; + } + if (world != null) { + prepareOverworldEntry(world); + } + } + + private void prepareOverworldEntry(World world) { + WorldReplacementEntryGuard.Entry guard = overworldEntryGuard; + if (guard == null + || guard.transactionId().equals(overworldEntryAwaitingVerification) + || !OVERWORLD_KEY.equals(toWorldSlotKey(WorldIdentity.key(world)))) { + return; + } + CompletableFuture targetFuture; + synchronized (this) { + if (overworldSafeEntry != null) { + return; + } + targetFuture = new CompletableFuture<>(); + overworldSafeEntry = targetFuture; + overworldEntryWorld = world; + } + try { + PlatformChunkGenerator generator = IrisToolbelt.access(world); + if (!(generator instanceof BukkitChunkGenerator bukkitGenerator)) { + throw new IOException("The replaced Overworld does not have a Bukkit Iris generator."); + } + Location anchor = bukkitGenerator.getInitialSpawnLocation(world); + int chunkX = anchor.getBlockX() >> 4; + int chunkZ = anchor.getBlockZ() >> 4; + WorldRuntimeControlService runtime = WorldRuntimeControlService.get(); + CompletableFuture chunkFuture = runtime.requestChunkAsync(world, chunkX, chunkZ, true); + if (chunkFuture == null) { + throw new IOException("The replacement spawn chunk request was not accepted."); + } + chunkFuture + .thenCompose(chunk -> runtime.resolveSafeEntry(world, anchor)) + .thenCompose(this::applyOverworldSpawn) + .thenCompose(this::persistOverworldSpawn) + .whenComplete((safeEntry, failure) -> { + if (failure != null) { + targetFuture.completeExceptionally(failure); + Iris.reportError("Could not prepare a safe spawn for the replaced Overworld.", failure); + return; + } + targetFuture.complete(safeEntry.clone()); + retireOverworldEntryIfComplete(guard.transactionId()); + }); + } catch (Throwable failure) { + targetFuture.completeExceptionally(failure); + Iris.reportError("Could not prepare a safe spawn for the replaced Overworld.", failure); + } + } + + private CompletableFuture applyOverworldSpawn(Location safeEntry) { + Location requiredSafeEntry = Objects.requireNonNull(safeEntry, "safeEntry").clone(); + World world = Objects.requireNonNull(requiredSafeEntry.getWorld(), "safeEntry.world"); + CompletableFuture applied = new CompletableFuture<>(); + int chunkX = requiredSafeEntry.getBlockX() >> 4; + int chunkZ = requiredSafeEntry.getBlockZ() >> 4; + boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { + try { + if (!world.setSpawnLocation(requiredSafeEntry)) { + throw new IOException("The server rejected the replacement spawn location."); + } + applied.complete(requiredSafeEntry.clone()); + } catch (Throwable failure) { + applied.completeExceptionally(failure); + } + }); + if (!scheduled) { + applied.completeExceptionally(new IOException("Could not schedule the replacement spawn update.")); + } + return applied; + } + + private CompletableFuture persistOverworldSpawn(Location safeEntry) { + Location requiredSafeEntry = Objects.requireNonNull(safeEntry, "safeEntry").clone(); + World world = Objects.requireNonNull(requiredSafeEntry.getWorld(), "safeEntry.world"); + CompletableFuture persisted = new CompletableFuture<>(); + boolean scheduled = J.runGlobal(() -> { + try { + world.save(); + persisted.complete(requiredSafeEntry.clone()); + } catch (Throwable failure) { + persisted.completeExceptionally(failure); + } + }); + if (!scheduled) { + persisted.completeExceptionally(new IOException("Could not schedule replacement spawn persistence.")); + } + return persisted; + } + + private CompletableFuture inspectLoginCollision(Location location) { + Location requiredLocation = Objects.requireNonNull(location, "location").clone(); + World world = Objects.requireNonNull(requiredLocation.getWorld(), "location.world"); + int chunkX = requiredLocation.getBlockX() >> 4; + int chunkZ = requiredLocation.getBlockZ() >> 4; + WorldRuntimeControlService runtime = WorldRuntimeControlService.get(); + CompletableFuture chunkFuture = runtime.requestChunkAsync(world, chunkX, chunkZ, true); + if (chunkFuture == null) { + return CompletableFuture.failedFuture(new IOException("The saved login chunk request was not accepted.")); + } + return chunkFuture.thenCompose(chunk -> inspectLoadedLoginCollision(requiredLocation)); + } + + private CompletableFuture inspectLoadedLoginCollision(Location location) { + World world = Objects.requireNonNull(location.getWorld(), "location.world"); + CompletableFuture inspected = new CompletableFuture<>(); + int chunkX = location.getBlockX() >> 4; + int chunkZ = location.getBlockZ() >> 4; + boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> { + try { + int blockY = location.getBlockY(); + if (blockY < world.getMinHeight() || blockY + 1 >= world.getMaxHeight()) { + inspected.complete(false); + return; + } + boolean feetPassable = world.getBlockAt(location.getBlockX(), blockY, location.getBlockZ()) + .isPassable(); + boolean headPassable = world.getBlockAt(location.getBlockX(), blockY + 1, location.getBlockZ()) + .isPassable(); + inspected.complete(feetPassable && headPassable); + } catch (Throwable failure) { + inspected.completeExceptionally(failure); + } + }); + if (!scheduled) { + inspected.completeExceptionally(new IOException("Could not schedule the saved login collision check.")); + } + return inspected; + } + + private void completeOverworldEntry(UUID playerId, UUID transactionId) { + try { + J.a(() -> completeOverworldEntryAsync(playerId, transactionId)); + } catch (Throwable failure) { + Iris.reportError("Could not record a completed Overworld replacement entry for " + playerId + ".", failure); + } + } + + private void completeOverworldEntryAsync(UUID playerId, UUID transactionId) { + boolean retire = false; + try { + synchronized (this) { + WorldReplacementEntryGuard.Entry current = overworldEntryGuard; + Path worldDirectory = overworldEntryWorldDirectory; + if (current == null + || worldDirectory == null + || !current.transactionId().equals(transactionId) + || !current.pendingPlayers().contains(playerId)) { + return; + } + Optional updated = WorldReplacementEntryGuard.completePlayer( + worldDirectory, + transactionId, + playerId + ); + overworldEntryGuard = updated.orElse(null); + retire = overworldEntryGuard != null && overworldEntryGuard.pendingPlayers().isEmpty(); + } + } catch (Throwable failure) { + Iris.reportError("Could not record a completed Overworld replacement entry for " + playerId + ".", failure); + return; + } + if (retire) { + retireOverworldEntryIfCompleteAsync(transactionId); + } + } + + private void retireOverworldEntryIfComplete(UUID transactionId) { + try { + J.a(() -> retireOverworldEntryIfCompleteAsync(transactionId)); + } catch (Throwable failure) { + Iris.reportError("Could not schedule Overworld replacement entry marker retirement.", failure); + } + } + + private synchronized void retireOverworldEntryIfCompleteAsync(UUID transactionId) { + WorldReplacementEntryGuard.Entry current = overworldEntryGuard; + Path worldDirectory = overworldEntryWorldDirectory; + CompletableFuture safeEntry = overworldSafeEntry; + if (current == null + || worldDirectory == null + || !current.transactionId().equals(transactionId) + || !current.pendingPlayers().isEmpty() + || safeEntry == null + || !safeEntry.isDone() + || safeEntry.isCompletedExceptionally()) { + return; + } + try { + if (!WorldReplacementEntryGuard.retireIfEmpty(worldDirectory, transactionId)) { + return; + } + overworldEntryGuard = null; + overworldEntryWorldDirectory = null; + overworldSafeEntry = null; + overworldEntryWorld = null; + pendingEntryAcknowledgements.entrySet().removeIf(entry -> entry.getValue().equals(transactionId)); + } catch (Throwable failure) { + Iris.reportError("Could not retire the completed Overworld replacement entry marker.", failure); + } + } + + void reportUnsafeEntry(UUID playerId, Throwable failure) { + Iris.reportError("Refused unsafe Overworld replacement entry for " + playerId + ".", failure); + } + + private synchronized void failOverworldEntryPreparation(Throwable failure) { + CompletableFuture future = overworldSafeEntry; + if (future == null) { + future = new CompletableFuture<>(); + overworldSafeEntry = future; + } + future.completeExceptionally(failure); + Iris.reportError("Could not prepare a safe spawn for the replaced Overworld.", failure); } @EventHandler(priority = EventPriority.MONITOR) @@ -269,9 +575,81 @@ public final class PendingWorldReplacementManager implements Listener { Iris.reportError("Failed to capture a loaded world identity for replacement verification.", failure); return; } + if (OVERWORLD_KEY.equals(worldKey)) { + prepareOverworldEntry(event.getWorld()); + } J.a(() -> discoverLoadedWorldTransaction(worldKey)); } + ReplacementEntryRedirect prepareReplacementEntry(UUID playerId, Location savedLocation, boolean newPlayer) + throws IOException, InterruptedException, ExecutionException, TimeoutException { + WorldReplacementEntryGuard.Entry guard = overworldEntryGuard; + if (guard == null || playerId == null) { + return null; + } + boolean pendingPlayer = guard.pendingPlayers().contains(playerId); + if (!pendingPlayer && !newPlayer) { + return null; + } + Location requiredSavedLocation = Objects.requireNonNull(savedLocation, "savedLocation").clone(); + World replacementWorld = overworldEntryWorld; + if (replacementWorld == null) { + throw new IOException("The replacement Overworld is not ready."); + } + if (requiredSavedLocation.getWorld() != replacementWorld) { + if (pendingPlayer) { + completeOverworldEntry(playerId, guard.transactionId()); + } + return null; + } + if (!newPlayer) { + boolean collisionSafe = inspectLoginCollision(requiredSavedLocation) + .get(SAFE_ENTRY_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (collisionSafe) { + completeOverworldEntry(playerId, guard.transactionId()); + return null; + } + } + CompletableFuture safeEntry = overworldSafeEntry; + if (safeEntry == null) { + throw new IOException("The replacement safe spawn is not ready."); + } + Location prepared = safeEntry.get(SAFE_ENTRY_TIMEOUT_SECONDS, TimeUnit.SECONDS).clone(); + prepared.setYaw(requiredSavedLocation.getYaw()); + prepared.setPitch(requiredSavedLocation.getPitch()); + return new ReplacementEntryRedirect(guard.transactionId(), prepared, pendingPlayer); + } + + void expectReplacementEntryAcknowledgement(UUID playerId, UUID transactionId) throws IOException { + WorldReplacementEntryGuard.Entry guard = overworldEntryGuard; + if (guard == null + || !guard.transactionId().equals(transactionId) + || !guard.pendingPlayers().contains(playerId)) { + throw new IOException("The Overworld replacement entry receipt is no longer active."); + } + pendingEntryAcknowledgements.put(playerId, transactionId); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerJoin(PlayerJoinEvent event) { + UUID playerId = event.getPlayer().getUniqueId(); + UUID transactionId = pendingEntryAcknowledgements.remove(playerId); + if (transactionId == null) { + return; + } + boolean scheduled = J.runEntity(event.getPlayer(), () -> { + try { + event.getPlayer().saveData(); + completeOverworldEntry(playerId, transactionId); + } catch (Throwable failure) { + Iris.reportError("Could not persist safe Overworld replacement entry for " + playerId + ".", failure); + } + }); + if (!scheduled) { + Iris.error("Could not schedule safe Overworld replacement entry persistence for " + playerId + "."); + } + } + private void discoverLoadedWorldTransaction(WorldSlotKey worldKey) { Transaction transaction; try { @@ -412,6 +790,10 @@ public final class PendingWorldReplacementManager implements Listener { try { writeTransaction(committed); Iris.success("Committed Iris world replacement for " + transaction.worldKey() + "."); + if (OVERWORLD_KEY.equals(transaction.worldKey())) { + overworldEntryAwaitingVerification = null; + scheduleLoadedOverworldEntryPreparation(); + } scheduleCommittedCleanup(committed); } catch (Throwable failure) { Iris.reportError("The replacement for " + transaction.worldKey() @@ -456,6 +838,9 @@ public final class PendingWorldReplacementManager implements Listener { private void initiateRollback(Transaction transaction, Throwable failure) { Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey() + "; the retained world will be restored on restart.", failure); + if (OVERWORLD_KEY.equals(transaction.worldKey())) { + clearOverworldEntryGuard(); + } try { Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING); writeTransaction(rollback); @@ -608,6 +993,24 @@ public final class PendingWorldReplacementManager implements Listener { return plugin.getDataFolder().toPath().toAbsolutePath().normalize(); } + private synchronized void loadOverworldEntryGuard() throws IOException { + ExactWorldSlotPathPolicy.Target target = resolveTarget(OVERWORLD_KEY); + Optional loaded = WorldReplacementEntryGuard.load(target.worldDirectory()); + overworldEntryGuard = loaded.orElse(null); + overworldEntryWorldDirectory = loaded.isPresent() ? target.worldDirectory() : null; + overworldSafeEntry = null; + overworldEntryAwaitingVerification = null; + } + + private synchronized void clearOverworldEntryGuard() { + overworldEntryGuard = null; + overworldEntryWorldDirectory = null; + overworldSafeEntry = null; + overworldEntryAwaitingVerification = null; + overworldEntryWorld = null; + pendingEntryAcknowledgements.clear(); + } + private ExactWorldSlotPathPolicy.Target resolveTransactionTarget(Transaction transaction) throws IOException { return WorldReplacementJournal.resolveTarget(transaction, IrisWorldStorage.levelRoot().toPath()); } @@ -738,6 +1141,22 @@ public final class PendingWorldReplacementManager implements Listener { private record VanillaLevelContext(boolean allowNether, boolean allowEnd) { } + record ReplacementEntryRedirect( + UUID transactionId, + Location location, + boolean acknowledgementRequired + ) { + ReplacementEntryRedirect { + Objects.requireNonNull(transactionId, "transactionId"); + location = Objects.requireNonNull(location, "location").clone(); + } + + @Override + public Location location() { + return location.clone(); + } + } + private static final class RestartBoundaryRequired extends IOException { private RestartBoundaryRequired(String message) { super(message); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java index 085fd28a1..9452c571c 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandIris.java @@ -801,8 +801,14 @@ public class CommandIris implements DirectorExecutor { } boolean doesWorldExist(String worldName) { - File worldDirectory = IrisWorldStorage.dimensionRoot(worldName); - return worldDirectory.exists() && worldDirectory.isDirectory(); + NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(worldName); + File levelRoot = IrisWorldStorage.levelRoot(); + return IrisWorldStorage.frozenDimensionRoot( + Bukkit.getWorldContainer(), + levelRoot, + IrisWorldStorage.configuredWorldName(worldKey, levelRoot.getName()), + worldKey + ).isPresent(); } public static class ManagedWorldNameHandler implements DirectorParameterHandler { diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java index e1f1d956f..f94c19bf2 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/IrisWorldGeneratorResolverTest.java @@ -93,6 +93,61 @@ public class IrisWorldGeneratorResolverTest { ); } + @Test + public void configuredWorldResolutionUsesOnlyFrozenWorldLocalSnapshot() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java")); + int resolverStart = source.indexOf("private ChunkGenerator resolveFrozenWorldGenerator("); + int resolverEnd = source.indexOf("private record FreshValidation", resolverStart); + String resolver = source.substring(resolverStart, resolverEnd); + + int dimensionRoot = resolver.indexOf("IrisWorldStorage.requireFrozenDimensionRoot("); + int currentPlatformRoot = resolver.indexOf("WorldCreatorCompat.persistentDimensionRoot(worldKey)"); + int layoutRefusal = resolver.indexOf( + "Frozen Iris world storage does not match the current platform layout", + currentPlatformRoot + ); + int snapshotRoot = resolver.indexOf("IrisWorldStorage.requireFrozenPackRoot(dimensionRoot)"); + int validation = resolver.indexOf("requireSnapshotLoadable(snapshotRoot)"); + int exactLoad = resolver.indexOf( + "IrisData.get(snapshotRoot).getDimensionLoader().load(id, false)" + ); + int canonicalIdentity = resolver.indexOf(".platformIdentity(worldKey.toString())"); + int resolvedStorage = resolver.indexOf(".worldFolder(dimensionRoot)"); + + assertTrue(dimensionRoot >= 0); + assertTrue(currentPlatformRoot > dimensionRoot); + assertTrue(layoutRefusal > currentPlatformRoot); + assertTrue(snapshotRoot > layoutRefusal); + assertTrue(validation > snapshotRoot); + assertTrue(exactLoad > validation); + assertTrue(canonicalIdentity > exactLoad); + assertTrue(resolvedStorage > canonicalIdentity); + assertFalse(resolver.contains("loadDimension(")); + assertFalse(resolver.contains("loadAnyDimension(")); + assertFalse(resolver.contains("replaceIntoWorld(")); + assertFalse(resolver.contains("installIntoWorld(")); + } + + @Test + public void configuredWorldSnapshotFailureStopsStartupAndRethrows() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java")); + int resolverStart = source.indexOf("public ChunkGenerator resolveDefaultWorldGenerator("); + int resolverEnd = source.indexOf("private ChunkGenerator resolveFrozenWorldGenerator(", resolverStart); + String resolver = source.substring(resolverStart, resolverEnd); + + int failureCapture = resolver.indexOf("catch (RuntimeException failure)"); + int report = resolver.indexOf("Iris.reportError(", failureCapture); + int shutdown = resolver.indexOf("Bukkit.shutdown()", report); + int rethrow = resolver.indexOf("throw failure", shutdown); + + assertTrue(failureCapture >= 0); + assertTrue(report > failureCapture); + assertTrue(shutdown > report); + assertTrue(rethrow > shutdown); + } + private static void writeValidPack(Path packRoot) throws Exception { Files.createDirectories(packRoot.resolve("dimensions")); Files.createDirectories(packRoot.resolve("regions")); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldDeleteQueueTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldDeleteQueueTest.java index 9cf089b32..af7ee556f 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldDeleteQueueTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldDeleteQueueTest.java @@ -174,6 +174,19 @@ public class PendingWorldDeleteQueueTest { ), family); } + @Test + public void exactLogicalEntryResolvesCurrentCraftBukkitLevelStorage() throws IOException { + Path worldContainer = temporaryFolder.newFolder("configured-delete-server").toPath(); + File levelRoot = Files.createDirectory(worldContainer.resolve("world")).toFile(); + Path configuredLevelRoot = worldContainer.resolve("world_iris_alpha"); + Files.createDirectories(configuredLevelRoot.resolve("dimensions/iris/alpha")); + + assertEquals( + List.of(configuredLevelRoot.toAbsolutePath().normalize()), + PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, "exact:alpha") + ); + } + @Test public void failedSafeDeletionSignalsQueueRetentionAndSucceedsOnRetry() throws IOException { File levelRoot = temporaryFolder.newFolder("retry-world"); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementThreadAffinityTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementThreadAffinityTest.java index c07530f3e..587d1c111 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementThreadAffinityTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/PendingWorldReplacementThreadAffinityTest.java @@ -164,6 +164,84 @@ public class PendingWorldReplacementThreadAffinityTest { assertFalse(irisSource.contains("J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds)")); } + @Test + public void paperLoginHookIsIsolatedFromAlwaysLoadedSpigotClasses() throws Exception { + String managerSource = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java")); + String irisSource = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java")); + String listenerSource = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/PaperWorldReplacementEntryListener.java")); + String registration = method(managerSource, "public void registerPlatformEntryListener()"); + + assertFalse(managerSource.contains("import io.papermc.paper.event.player.AsyncPlayerSpawnLocationEvent")); + assertFalse(managerSource.contains("AsyncPlayerSpawnLocationEvent event")); + assertFalse(irisSource.contains("AsyncPlayerSpawnLocationEvent")); + assertTrue(registration.contains("Class.forName(\"io.papermc.paper.event.player.AsyncPlayerSpawnLocationEvent\"")); + assertTrue(registration.contains("Class.forName(")); + assertTrue(registration.contains("PaperWorldReplacementEntryListener")); + assertTrue(irisSource.contains("pendingWorldReplacements.registerPlatformEntryListener();")); + assertTrue(listenerSource.contains("onAsyncPlayerSpawnLocation(AsyncPlayerSpawnLocationEvent event)")); + } + + @Test + public void redirectedPlayerReceiptSurvivesUntilTheMaterializedPositionIsSaved() throws Exception { + String managerSource = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java")); + String listenerSource = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/PaperWorldReplacementEntryListener.java")); + String preparation = method( + managerSource, + "ReplacementEntryRedirect prepareReplacementEntry(UUID playerId, Location savedLocation, boolean newPlayer)" + ); + String acknowledgement = method( + managerSource, + "void expectReplacementEntryAcknowledgement(UUID playerId, UUID transactionId)" + ); + String join = method(managerSource, "public void onPlayerJoin(PlayerJoinEvent event)"); + String listener = method( + listenerSource, + "public void onAsyncPlayerSpawnLocation(AsyncPlayerSpawnLocationEvent event)" + ); + + assertTrue(preparation.contains("return new ReplacementEntryRedirect(guard.transactionId(), prepared, pendingPlayer)")); + assertFalse(preparation.substring(preparation.indexOf("CompletableFuture safeEntry")) + .contains("completeOverworldEntry(")); + assertBefore(listener, "event.setSpawnLocation(location)", + "manager.expectReplacementEntryAcknowledgement(playerId, redirect.transactionId())"); + assertTrue(acknowledgement.contains("pendingEntryAcknowledgements.put(playerId, transactionId)")); + assertBefore(join, "event.getPlayer().saveData()", "completeOverworldEntry(playerId, transactionId)"); + } + + @Test + public void replacementSpawnIsPersistedBeforeFinalMarkerRetirement() throws Exception { + String managerSource = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java")); + String listenerSource = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/PaperWorldReplacementEntryListener.java")); + String preparation = method(managerSource, "private void prepareOverworldEntry(World world)"); + String persistence = method(managerSource, "private CompletableFuture persistOverworldSpawn(Location safeEntry)"); + String retirement = method( + managerSource, + "private synchronized void retireOverworldEntryIfCompleteAsync(UUID transactionId)" + ); + String listener = method( + listenerSource, + "public void onAsyncPlayerSpawnLocation(AsyncPlayerSpawnLocationEvent event)" + ); + String generatorSource = Files.readString(Path.of(System.getProperty("iris.bukkitChunkGeneratorSource"))); + + assertBefore(preparation, ".thenCompose(this::applyOverworldSpawn)", + ".thenCompose(this::persistOverworldSpawn)"); + assertBefore(preparation, "targetFuture.complete(safeEntry.clone())", + "retireOverworldEntryIfComplete(guard.transactionId())"); + assertTrue(persistence.contains("world.save()")); + assertTrue(retirement.contains("!current.pendingPlayers().isEmpty()")); + assertTrue(retirement.contains("!safeEntry.isDone()")); + assertTrue(retirement.contains("safeEntry.isCompletedExceptionally()")); + assertTrue(listener.contains("event.isNewPlayer()")); + assertTrue(generatorSource.contains("world.getHighestBlockYAt(initialSpawn) + 1")); + } + private static PendingWorldReplacementManager.PublishedWorldRuntimeState runtimeState( WorldSlotKey worldKey, long seed, diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index 8cf5e181b..70bcdcc74 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -31,7 +31,7 @@ file('../../gradle.properties').withInputStream { InputStream stream -> rootProp String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.0.0-26.2')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2')) String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.3')) -String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969')) +String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')) Closure irisArtifactName = { String platform, String targetVersion -> return "Iris v${project.version} [${platform}] ${targetVersion}.jar" } diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index b220a1369..5a4dad964 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -30,8 +30,8 @@ Properties rootProperties = new Properties() file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.0.0-26.2')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2')) -String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.2-65.0.4')) -String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969')) +String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.2-65.1.1')) +String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')) Closure loaderDisplayVersion = { String loaderVersion -> String coordinatePrefix = "${minecraftVersion}-" if (loaderVersion.startsWith(coordinatePrefix)) { diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java index 6c80b2ce6..2e5ba4e7a 100644 --- a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureVolumeIndex.java @@ -360,9 +360,8 @@ public final class NativeStructureVolumeIndex { private static void warnOnce(String structureId, Throwable error) { if (WARNED_RESOLUTION_FAILURES.add(structureId)) { - IrisLogging.warn("Native structure volume resolution failed for '" + structureId - + "'; objects will not be vetoed against it: " - + error.getClass().getSimpleName() + ":" + error.getMessage()); + IrisLogging.reportError("Native structure volume resolution failed for '" + structureId + + "'; objects will not be vetoed against it.", error); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java index c86bc8d0d..f88bdb479 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java @@ -53,6 +53,10 @@ public final class ModdedMixinAudit { new ExpectedMixin("MobAwarenessMixin", "entity", "net.minecraft.world.entity.Mob", "iris$tickUnawareMob", false, ModdedMixinFlags::mobAwarenessRan), + new ExpectedMixin("StructureTemplatePaletteConcurrencyMixin", "common", + "net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate$Palette", + "iris$installConcurrentBlockCache", + false, ModdedMixinFlags::structureTemplatePaletteRan), new ExpectedMixin("IrisWorldOpenFlowsMixin", "client", "net.minecraft.client.gui.screens.worldselection.WorldOpenFlows", "iris$openWorldCheckWorldStemCompatibility", @@ -103,7 +107,7 @@ public final class ModdedMixinAudit { LOGGER.error(" missing: {}", entry); } LOGGER.error("The mixin config was not registered for this loader (fabric.mod.json mixins, neoforge.mods.toml [[mixins]], forge MixinConfigs manifest attribute)."); - LOGGER.error("Entity persistence, custom mob loot and Iris world-type labels are disabled until this is fixed."); + LOGGER.error("Entity persistence, custom mob loot, parallel structure safety, or Iris world-type labels are disabled until this is fixed."); LOGGER.error("==============================================================="); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinFlags.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinFlags.java index 264ac316f..3695d2080 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinFlags.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinFlags.java @@ -31,6 +31,7 @@ public final class ModdedMixinFlags { private static volatile boolean entityPersistenceRan; private static volatile boolean livingEntityLootRan; private static volatile boolean mobAwarenessRan; + private static volatile boolean structureTemplatePaletteRan; private static volatile boolean worldOpenFlowsRan; private static volatile boolean worldTypeEntryRan; @@ -58,6 +59,12 @@ public final class ModdedMixinFlags { } } + public static void markStructureTemplatePalette() { + if (!structureTemplatePaletteRan) { + structureTemplatePaletteRan = true; + } + } + public static void markWorldOpenFlows() { if (!worldOpenFlowsRan) { worldOpenFlowsRan = true; @@ -82,6 +89,10 @@ public final class ModdedMixinFlags { return mobAwarenessRan; } + public static boolean structureTemplatePaletteRan() { + return structureTemplatePaletteRan; + } + public static boolean worldOpenFlowsRan() { return worldOpenFlowsRan; } @@ -94,6 +105,7 @@ public final class ModdedMixinFlags { entityPersistenceRan = false; livingEntityLootRan = false; mobAwarenessRan = false; + structureTemplatePaletteRan = false; worldOpenFlowsRan = false; worldTypeEntryRan = false; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java index 679d82882..e092de80d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java @@ -50,10 +50,12 @@ 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.entity.ai.village.poi.PoiTypes; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.LevelHeightAccessor; import net.minecraft.world.level.StructureManager; import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.biome.BiomeSource; import net.minecraft.world.level.chunk.ChunkAccess; @@ -73,6 +75,7 @@ import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; import java.util.function.IntBinaryOperator; /** @@ -346,6 +349,11 @@ final class ModdedNativeStructureStage { index++; } } + if (!placementGroups.isEmpty()) { + ServerLevel level = world.getLevel(); + visitExistingPois(chunk, (position, state) -> level.updatePOIOnBlockStateChange( + position, Blocks.AIR.defaultBlockState(), state)); + } try { int runtimeMinY = world.getMinY(); WorldgenTerrainHeightmaps.primeStructurePlacement( @@ -405,6 +413,10 @@ final class ModdedNativeStructureStage { } } + static void visitExistingPois(ChunkAccess chunk, BiConsumer visitor) { + chunk.findBlocks(PoiTypes::hasPoi, visitor); + } + private static String nativeStructureBatchContext(List placementGroups) { if (placementGroups.isEmpty()) { return ""; @@ -423,9 +435,21 @@ final class ModdedNativeStructureStage { WorldgenRandom random, BoundingBox area, ChunkPos chunkPos, String structureId, StructureStart start, IrisNativeStructureDecision decision) { - NativeStructurePostProcessor.place(world, structureManager, generator, random, area, chunkPos, - structureId, start, decision, this::resolvePaletteBlock, - (x, z) -> generator.engine().getHeight(x, z, true) + generator.engine().getMinHeight()); + Engine current = generator.engine(); + int runtimeMinY = world.getMinY(); + WorldGenLevel boundedWorld = ModdedNativeStructureWorldgenAccess.create( + world, chunkPos, + worldgenSurfaceHeight(current, runtimeMinY), + worldgenFloorHeight(current, runtimeMinY)); + world.setCurrentlyGenerating(() -> "Iris native structure " + structureId); + try { + NativeStructurePostProcessor.place( + boundedWorld, structureManager, generator, random, area, chunkPos, + structureId, start, decision, this::resolvePaletteBlock, + (x, z) -> current.getHeight(x, z, true) + current.getMinHeight()); + } finally { + world.setCurrentlyGenerating(null); + } } private List> structuresByStep(Registry registry) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccess.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccess.java new file mode 100644 index 000000000..9aa074711 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccess.java @@ -0,0 +1,558 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps; +import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Holder; +import net.minecraft.core.QuartPos; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.DifficultyInstance; +import net.minecraft.world.attribute.EnvironmentAttributeReader; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.flag.FeatureFlagSet; +import net.minecraft.world.level.BlockGetter; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LightLayer; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.border.WorldBorder; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkSource; +import net.minecraft.world.level.chunk.EmptyLevelChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.dimension.DimensionType; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.gameevent.GameEvent; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.lighting.LevelLightEngine; +import net.minecraft.world.level.material.Fluid; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.level.storage.LevelData; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.ticks.LevelTickAccess; +import net.minecraft.world.ticks.ScheduledTick; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.IntBinaryOperator; +import java.util.function.Predicate; +import java.util.function.Supplier; + +final class ModdedNativeStructureWorldgenAccess implements WorldGenLevel { + private static final int WRITE_RADIUS = 1; + + private final WorldGenLevel delegate; + private final ChunkPos generationCenter; + private final IntBinaryOperator surfaceFirstFreeY; + private final IntBinaryOperator floorFirstFreeY; + private final Holder fallbackBiome; + private final BiomeManager biomeManager; + private final AABB generationBounds; + private final LevelTickAccess blockTicks; + private final LevelTickAccess fluidTicks; + private final Long2LongOpenHashMap terrainHeights; + private final Long2ObjectOpenHashMap outsideChunks; + + private ModdedNativeStructureWorldgenAccess(WorldGenLevel delegate, Boundary boundary) { + this.delegate = Objects.requireNonNull(delegate, "Native structure world access requires a delegate"); + this.generationCenter = Objects.requireNonNull( + boundary.generationCenter(), "Native structure world access requires a generation center"); + this.surfaceFirstFreeY = Objects.requireNonNull( + boundary.surfaceFirstFreeY(), "Native structure world access requires a surface resolver"); + this.floorFirstFreeY = Objects.requireNonNull( + boundary.floorFirstFreeY(), "Native structure world access requires an ocean-floor resolver"); + BlockPos biomeSample = generationCenter.getMiddleBlockPosition(delegate.getSeaLevel()); + this.fallbackBiome = delegate.getBiome(biomeSample); + this.biomeManager = delegate.getBiomeManager().withDifferentSource(this); + this.generationBounds = new AABB( + generationCenter.getMinBlockX() - 16, + delegate.getMinY(), + generationCenter.getMinBlockZ() - 16, + generationCenter.getMaxBlockX() + 17, + delegate.getMinY() + delegate.getHeight(), + generationCenter.getMaxBlockZ() + 17); + this.blockTicks = new BoundedTickAccess<>(delegate.getBlockTicks(), this::isWritable); + this.fluidTicks = new BoundedTickAccess<>(delegate.getFluidTicks(), this::isWritable); + this.terrainHeights = new Long2LongOpenHashMap(); + this.terrainHeights.defaultReturnValue(Long.MIN_VALUE); + this.outsideChunks = new Long2ObjectOpenHashMap<>(); + } + + static ModdedNativeStructureWorldgenAccess create(WorldGenLevel delegate, ChunkPos generationCenter, + IntBinaryOperator surfaceFirstFreeY, + IntBinaryOperator floorFirstFreeY) { + return new ModdedNativeStructureWorldgenAccess(delegate, new Boundary( + generationCenter, surfaceFirstFreeY, floorFirstFreeY)); + } + + @Override + public long getSeed() { + return delegate.getSeed(); + } + + @Override + public boolean ensureCanWrite(BlockPos position) { + return isWritable(position) && delegate.ensureCanWrite(position); + } + + @Override + public void setCurrentlyGenerating(Supplier description) { + delegate.setCurrentlyGenerating(description); + } + + @Override + public ServerLevel getLevel() { + return delegate.getLevel(); + } + + @Override + public DifficultyInstance getCurrentDifficultyAt(BlockPos position) { + if (isReadable(position)) { + return delegate.getCurrentDifficultyAt(position); + } + return delegate.getCurrentDifficultyAt(generationCenter.getMiddleBlockPosition( + Mth.clamp(position.getY(), getMinY(), getMaxY() - 1))); + } + + @Override + public long nextSubTickCount() { + return delegate.nextSubTickCount(); + } + + @Override + public LevelTickAccess getBlockTicks() { + return blockTicks; + } + + @Override + public LevelTickAccess getFluidTicks() { + return fluidTicks; + } + + @Override + public LevelData getLevelData() { + return delegate.getLevelData(); + } + + @Override + public MinecraftServer getServer() { + return delegate.getServer(); + } + + @Override + public ChunkSource getChunkSource() { + return delegate.getChunkSource(); + } + + @Override + public RandomSource getRandom() { + return delegate.getRandom(); + } + + @Override + public void updateNeighborsAt(BlockPos position, Block block) { + if (isWritableNeighbourhood(position)) { + delegate.updateNeighborsAt(position, block); + } + } + + @Override + public void neighborShapeChanged(Direction direction, BlockPos position, + BlockPos neighbourPosition, BlockState neighbourState, + int updateFlags, int updateLimit) { + if (isWritable(position) && isWritable(neighbourPosition)) { + delegate.neighborShapeChanged( + direction, position, neighbourPosition, neighbourState, updateFlags, updateLimit); + } + } + + @Override + public void playSound(Entity source, BlockPos position, SoundEvent sound, + SoundSource soundSource, float volume, float pitch) { + if (isWritable(position)) { + delegate.playSound(source, position, sound, soundSource, volume, pitch); + } + } + + @Override + public void addParticle(ParticleOptions particle, double x, double y, double z, + double velocityX, double velocityY, double velocityZ) { + if (isWritable(BlockPos.containing(x, y, z))) { + delegate.addParticle(particle, x, y, z, velocityX, velocityY, velocityZ); + } + } + + @Override + public void levelEvent(Entity source, int eventId, BlockPos position, int data) { + if (isWritable(position)) { + delegate.levelEvent(source, eventId, position, data); + } + } + + @Override + public void gameEvent(Holder event, Vec3 position, GameEvent.Context context) { + if (isWritable(BlockPos.containing(position))) { + delegate.gameEvent(event, position, context); + } + } + + @Override + public ChunkAccess getChunk(int chunkX, int chunkZ) { + if (isInsideGenerationRegion(chunkX, chunkZ)) { + return delegate.getChunk(chunkX, chunkZ); + } + return outsideChunk(chunkX, chunkZ); + } + + @Override + public ChunkAccess getChunk(int chunkX, int chunkZ, ChunkStatus status, boolean create) { + if (isInsideGenerationRegion(chunkX, chunkZ)) { + return delegate.getChunk(chunkX, chunkZ, status, create); + } + return create ? outsideChunk(chunkX, chunkZ) : null; + } + + @Override + public boolean hasChunk(int chunkX, int chunkZ) { + return isInsideGenerationRegion(chunkX, chunkZ) && delegate.hasChunk(chunkX, chunkZ); + } + + @Override + public int getHeight(Heightmap.Types type, int x, int z) { + if (isInsideGenerationRegion(x >> 4, z >> 4)) { + return delegate.getHeight(type, x, z); + } + return height(type, x, z); + } + + @Override + public int getSkyDarken() { + return delegate.getSkyDarken(); + } + + @Override + public BiomeManager getBiomeManager() { + return biomeManager; + } + + @Override + public Holder getNoiseBiome(int quartX, int quartY, int quartZ) { + return getUncachedNoiseBiome(quartX, quartY, quartZ); + } + + @Override + public Holder getUncachedNoiseBiome(int quartX, int quartY, int quartZ) { + if (isInsideGenerationRegion(QuartPos.toSection(quartX), QuartPos.toSection(quartZ))) { + return delegate.getUncachedNoiseBiome(quartX, quartY, quartZ); + } + return fallbackBiome; + } + + @Override + public boolean isClientSide() { + return delegate.isClientSide(); + } + + @Override + public int getSeaLevel() { + return delegate.getSeaLevel(); + } + + @Override + public DimensionType dimensionType() { + return delegate.dimensionType(); + } + + @Override + public int getMinY() { + return delegate.getMinY(); + } + + @Override + public int getHeight() { + return delegate.getHeight(); + } + + @Override + public RegistryAccess registryAccess() { + return delegate.registryAccess(); + } + + @Override + public FeatureFlagSet enabledFeatures() { + return delegate.enabledFeatures(); + } + + @Override + public EnvironmentAttributeReader environmentAttributes() { + return delegate.environmentAttributes(); + } + + @Override + public LevelLightEngine getLightEngine() { + return delegate.getLightEngine(); + } + + @Override + public int getBrightness(LightLayer layer, BlockPos position) { + if (isReadable(position)) { + return delegate.getBrightness(layer, position); + } + return layer == LightLayer.SKY && canSeeSky(position) ? 15 : 0; + } + + @Override + public int getRawBrightness(BlockPos position, int ambientDarkening) { + if (isReadable(position)) { + return delegate.getRawBrightness(position, ambientDarkening); + } + return Math.max(0, getBrightness(LightLayer.SKY, position) - ambientDarkening); + } + + @Override + public boolean canSeeSky(BlockPos position) { + if (isReadable(position)) { + return delegate.canSeeSky(position); + } + return position.getY() >= height(Heightmap.Types.WORLD_SURFACE_WG, position.getX(), position.getZ()); + } + + @Override + public WorldBorder getWorldBorder() { + return delegate.getWorldBorder(); + } + + @Override + public BlockGetter getChunkForCollisions(int chunkX, int chunkZ) { + if (isInsideGenerationRegion(chunkX, chunkZ)) { + return delegate.getChunkForCollisions(chunkX, chunkZ); + } + return outsideChunk(chunkX, chunkZ); + } + + @Override + public BlockEntity getBlockEntity(BlockPos position) { + return isReadable(position) ? delegate.getBlockEntity(position) : null; + } + + @Override + public Optional getBlockEntity( + BlockPos position, BlockEntityType type) { + return isReadable(position) ? delegate.getBlockEntity(position, type) : Optional.empty(); + } + + @Override + public BlockState getBlockState(BlockPos position) { + return isReadable(position) ? delegate.getBlockState(position) : terrainState(position); + } + + @Override + public FluidState getFluidState(BlockPos position) { + return isReadable(position) ? delegate.getFluidState(position) : terrainState(position).getFluidState(); + } + + @Override + public List getEntities(Entity source, AABB area, Predicate predicate) { + AABB boundedArea = boundedArea(area); + return boundedArea == null ? List.of() : delegate.getEntities(source, boundedArea, predicate); + } + + @Override + public List getEntities( + EntityTypeTest type, + AABB area, Predicate predicate) { + AABB boundedArea = boundedArea(area); + return boundedArea == null ? List.of() : delegate.getEntities(type, boundedArea, predicate); + } + + @Override + public List players() { + return delegate.players(); + } + + @Override + public boolean isStateAtPosition(BlockPos position, Predicate predicate) { + return predicate.test(getBlockState(position)); + } + + @Override + public boolean isFluidAtPosition(BlockPos position, Predicate predicate) { + return predicate.test(getFluidState(position)); + } + + @Override + public BlockPos getHeightmapPos(Heightmap.Types type, BlockPos position) { + return position.atY(getHeight(type, position.getX(), position.getZ())); + } + + @Override + public boolean setBlock(BlockPos position, BlockState state, int updateFlags, int updateLimit) { + return isWritable(position) && delegate.setBlock(position, state, updateFlags, updateLimit); + } + + @Override + public boolean removeBlock(BlockPos position, boolean move) { + return isWritable(position) && delegate.removeBlock(position, move); + } + + @Override + public boolean destroyBlock(BlockPos position, boolean drop, Entity source, int updateLimit) { + return isWritable(position) && delegate.destroyBlock(position, drop, source, updateLimit); + } + + @Override + public boolean addFreshEntity(Entity entity) { + return isWritable(entity.blockPosition()) && delegate.addFreshEntity(entity); + } + + + private boolean isReadable(BlockPos position) { + return isInsideGenerationRegion(position.getX() >> 4, position.getZ() >> 4) + && !isOutsideBuildHeight(position); + } + + private boolean isWritable(BlockPos position) { + return isReadable(position); + } + + private boolean isWritableNeighbourhood(BlockPos position) { + return isWritable(position) + && isWritable(position.north()) + && isWritable(position.south()) + && isWritable(position.east()) + && isWritable(position.west()) + && isWritable(position.above()) + && isWritable(position.below()); + } + + private AABB boundedArea(AABB area) { + double minX = Math.max(area.minX, generationBounds.minX); + double minY = Math.max(area.minY, generationBounds.minY); + double minZ = Math.max(area.minZ, generationBounds.minZ); + double maxX = Math.min(area.maxX, generationBounds.maxX); + double maxY = Math.min(area.maxY, generationBounds.maxY); + double maxZ = Math.min(area.maxZ, generationBounds.maxZ); + if (minX >= maxX || minY >= maxY || minZ >= maxZ) { + return null; + } + if (minX == area.minX && minY == area.minY && minZ == area.minZ + && maxX == area.maxX && maxY == area.maxY && maxZ == area.maxZ) { + return area; + } + return new AABB(minX, minY, minZ, maxX, maxY, maxZ); + } + + boolean isInsideGenerationRegion(int chunkX, int chunkZ) { + return Math.abs(chunkX - generationCenter.x()) <= WRITE_RADIUS + && Math.abs(chunkZ - generationCenter.z()) <= WRITE_RADIUS; + } + + @Override + public boolean isOutsideBuildHeight(BlockPos position) { + return position.getY() < getMinY() || position.getY() >= getMaxY(); + } + + private BlockState terrainState(BlockPos position) { + if (isOutsideBuildHeight(position)) { + return Blocks.VOID_AIR.defaultBlockState(); + } + long heights = heights(position.getX(), position.getZ()); + int surface = (int) (heights >> 32); + int floor = (int) heights; + if (position.getY() < floor) { + return Blocks.STONE.defaultBlockState(); + } + if (position.getY() < surface) { + return Blocks.WATER.defaultBlockState(); + } + return Blocks.AIR.defaultBlockState(); + } + + private int height(Heightmap.Types type, int x, int z) { + long heights = heights(x, z); + if (type == Heightmap.Types.OCEAN_FLOOR || type == Heightmap.Types.OCEAN_FLOOR_WG) { + return (int) heights; + } + return (int) (heights >> 32); + } + + private long heights(int x, int z) { + long key = ((long) x << 32) ^ (z & 0xffffffffL); + long cached = terrainHeights.get(key); + if (cached != Long.MIN_VALUE) { + return cached; + } + int floor = Mth.clamp(floorFirstFreeY.applyAsInt(x, z), getMinY(), getMaxY()); + int surface = Mth.clamp(surfaceFirstFreeY.applyAsInt(x, z), floor, getMaxY()); + long heights = ((long) surface << 32) | (floor & 0xffffffffL); + terrainHeights.put(key, heights); + return heights; + } + + private ChunkAccess outsideChunk(int chunkX, int chunkZ) { + long key = ChunkPos.pack(chunkX, chunkZ); + ChunkAccess cached = outsideChunks.get(key); + if (cached != null) { + return cached; + } + EmptyLevelChunk chunk = new EmptyLevelChunk(getLevel(), new ChunkPos(chunkX, chunkZ), fallbackBiome); + WorldgenTerrainHeightmaps.primeTerrain(chunk, surfaceFirstFreeY, floorFirstFreeY); + outsideChunks.put(key, chunk); + return chunk; + } + + private record Boundary(ChunkPos generationCenter, + IntBinaryOperator surfaceFirstFreeY, + IntBinaryOperator floorFirstFreeY) { + } + + private static final class BoundedTickAccess implements LevelTickAccess { + private final LevelTickAccess delegate; + private final Predicate writable; + + private BoundedTickAccess(LevelTickAccess delegate, Predicate writable) { + this.delegate = delegate; + this.writable = writable; + } + + @Override + public void schedule(ScheduledTick tick) { + if (writable.test(tick.pos())) { + delegate.schedule(tick); + } + } + + @Override + public boolean hasScheduledTick(BlockPos position, T type) { + return writable.test(position) && delegate.hasScheduledTick(position, type); + } + + @Override + public int count() { + return delegate.count(); + } + + @Override + public boolean willTickThisTick(BlockPos position, T type) { + return writable.test(position) && delegate.willTickThisTick(position, type); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index d42e20990..17cb8ded6 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -344,7 +344,7 @@ public final class IrisModdedCommands { execution = new PackDownloadExecution( lease, - cancellation -> executeDownload(source, request, target, downloadSource, scheduler, cancellation) + cancellation -> executeDownload(source, request, target, downloadSource, cancellation) ); PackDownloadExecution trackedExecution = execution; execution.onCompletion(() -> clearActiveDownload(trackedExecution)); @@ -384,7 +384,6 @@ public final class IrisModdedCommands { DownloadRequest request, String target, String downloadSource, - ModdedScheduler scheduler, PackDownloader.DownloadCancellation cancellation ) throws PackDownloader.PackDownloadCancelledException { File packs = ModdedPackCommands.packsRoot(); @@ -394,37 +393,41 @@ public final class IrisModdedCommands { packs, request.url(), false, - (String message) -> scheduler.global(() -> ok(source, message)), + (String message) -> dispatchDownloadFeedback(source, () -> ok(source, message)), cancellation ) : PackDownloader.downloadBuiltIn( packs, request.pack(), false, - (String message) -> scheduler.global(() -> ok(source, message)), + (String message) -> dispatchDownloadFeedback(source, () -> ok(source, message)), cancellation ); String completionMessage = downloadCompletionMessage(result); if (result != null) { if (completionMessage != null) { - scheduler.global(() -> ok(source, completionMessage)); + dispatchDownloadFeedback(source, () -> ok(source, completionMessage)); } return; } } catch (PackDownloader.PackDownloadCancelledException error) { throw error; } catch (PackDownloader.PackDownloadBusyException error) { - scheduler.global(() -> fail(source, error.getMessage())); + dispatchDownloadFeedback(source, () -> fail(source, error.getMessage())); return; } catch (IOException | RuntimeException error) { LOGGER.error("Iris pack download failed for {}", target, error); } - scheduler.global(() -> fail(source, IrisLanguage.plain( + dispatchDownloadFeedback(source, () -> fail(source, IrisLanguage.plain( ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_DOWNLOAD_FAILED_SEE_CONSOLE, MessageArgument.untrusted("pack", target), MessageArgument.untrusted("downloadSource", downloadSource)))); } + private static void dispatchDownloadFeedback(CommandSourceStack source, Runnable feedback) { + source.getServer().execute(feedback); + } + static String downloadBusyMessage(LifecycleOperationCoordinator.ActiveOperation operation) { if (operation.domain() == LifecycleOperationCoordinator.Domain.PACK_MUTATION && operation.kind() == LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenCommands.java index 5d7d2e71d..a8bbee792 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenCommands.java @@ -51,7 +51,14 @@ final class ModdedPregenCommands { return 0; } boolean showGui = gui && ModdedGuiHost.isGuiLaunchable(); - if (!ModdedPregenJob.start(source.getServer(), level, engine, radius, centerX, centerZ, showGui, sync, !nocache)) { + boolean started; + try { + started = ModdedPregenJob.start(source.getServer(), level, engine, radius, centerX, centerZ, showGui, sync, !nocache); + } catch (IllegalArgumentException failure) { + IrisModdedCommands.fail(source, failure.getMessage()); + return 0; + } + if (!started) { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_PREGENERATION_TASK_IS_ALREADY_RUNNING_STOP_IT_FIRST_WITH_IRIS)); return 0; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java index 44795c27e..f35a9091b 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenJob.java @@ -55,13 +55,13 @@ public final class ModdedPregenJob { return false; } - PregenPerformanceProfile.apply(engine); PregenTask task = PregenTask.builder() .gui(gui) .center(new Position2(centerBlockX, centerBlockZ)) .radiusX(radiusBlocks) .radiusZ(radiusBlocks) .build(); + PregenPerformanceProfile.apply(engine); ModdedPregenMethod moddedMethod = new ModdedPregenMethod(level, engine, sync); PregeneratorMethod method = moddedMethod; if (cached) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java index a772fc30a..2d3930a0c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java @@ -73,6 +73,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod { private final AtomicBoolean finalSaveCompleted = new AtomicBoolean(false); private final AtomicReference queuedFinalSave = new AtomicReference<>(); private final AtomicBoolean stallHintLogged = new AtomicBoolean(false); + private final AtomicBoolean failureDetailLogged = new AtomicBoolean(false); private final int timeoutSeconds; private final PregenMantleBackpressure backpressure; private final PauseWhenEmptyGuard pauseGuard; @@ -350,7 +351,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod { if (e instanceof TimeoutException) { noteStallHint(); } - LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, e.toString()); + logChunkFailure(x, z, e); listener.onChunkFailed(x, z); } finally { markFinished(); @@ -395,7 +396,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod { if (unwrap(error) instanceof TimeoutException) { onTimeout(); } - LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, error.toString()); + logChunkFailure(x, z, error); listener.onChunkFailed(x, z); return; } @@ -421,6 +422,15 @@ public final class ModdedPregenMethod implements PregeneratorMethod { inFlightPeak.accumulateAndGet(current, Math::max); } + private void logChunkFailure(int x, int z, Throwable failure) { + Throwable cause = unwrap(failure); + if (failureDetailLogged.compareAndSet(false, true)) { + LOGGER.warn("Iris pregen chunk {},{} failed; first failure follows", x, z, cause); + return; + } + LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, cause.toString()); + } + private void markFinished() { inFlight.decrementAndGet(); if (sync) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/StructureTemplatePaletteConcurrencyMixin.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/StructureTemplatePaletteConcurrencyMixin.java new file mode 100644 index 000000000..3c6213d7b --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/StructureTemplatePaletteConcurrencyMixin.java @@ -0,0 +1,31 @@ +package art.arcane.iris.modded.mixin; + +import art.arcane.iris.modded.ModdedMixinFlags; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Mixin(StructureTemplate.Palette.class) +public abstract class StructureTemplatePaletteConcurrencyMixin { + @Shadow + @Final + @Mutable + private Map> cache; + + @Inject(method = "(Ljava/util/List;)V", at = @At("RETURN")) + private void iris$installConcurrentBlockCache(List blocks, + CallbackInfo info) { + cache = new ConcurrentHashMap<>(); + ModdedMixinFlags.markStructureTemplatePalette(); + } +} diff --git a/adapters/modded-common/src/main/resources/irisworldgen.entity.mixins.json b/adapters/modded-common/src/main/resources/irisworldgen.entity.mixins.json index 366956d9e..e7b5a28e2 100644 --- a/adapters/modded-common/src/main/resources/irisworldgen.entity.mixins.json +++ b/adapters/modded-common/src/main/resources/irisworldgen.entity.mixins.json @@ -6,7 +6,8 @@ "mixins": [ "EntityPersistenceMixin", "LivingEntityLootMixin", - "MobAwarenessMixin" + "MobAwarenessMixin", + "StructureTemplatePaletteConcurrencyMixin" ], "injectors": { "defaultRequire": 1 diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java index 9ab8055da..404788225 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java @@ -102,7 +102,12 @@ public class ModdedGenerationLeaseContractTest { String execution = method(source, "private static void executeDownload("); assertTrue(execution.contains("PackDownloader.DownloadCancellation cancellation")); assertTrue(execution.contains("catch (PackDownloader.PackDownloadCancelledException error)")); + assertTrue(execution.contains("dispatchDownloadFeedback(source,")); + assertFalse(execution.contains("scheduler.global(")); assertFalse(execution.contains("lease.close();")); + + String feedback = method(source, "private static void dispatchDownloadFeedback("); + assertTrue(feedback.contains("source.getServer().execute(feedback);")); } @Test @@ -170,6 +175,34 @@ public class ModdedGenerationLeaseContractTest { assertTrue(pending.contains("queuedFinalSave.get() != null")); } + @Test + public void firstPregenChunkFailureRetainsItsFullCause() throws IOException { + String methodSource = source("art/arcane/iris/modded/command/ModdedPregenMethod.java"); + String logFailure = method(methodSource, "private void logChunkFailure("); + + assertTrue(methodSource.contains("AtomicBoolean failureDetailLogged")); + assertTrue(logFailure.contains("unwrap(failure)")); + assertTrue(logFailure.contains("failureDetailLogged.compareAndSet(false, true)")); + assertTrue(logFailure.contains("x, z, cause);")); + assertTrue(logFailure.contains("cause.toString()")); + } + + @Test + public void invalidPregenBoundsFailBeforeRuntimeMutation() throws IOException { + String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java"); + String start = method(jobSource, "public static boolean start("); + int taskConstruction = start.indexOf("PregenTask task = PregenTask.builder()"); + int profileMutation = start.indexOf("PregenPerformanceProfile.apply(engine);"); + + assertTrue(taskConstruction >= 0); + assertTrue(profileMutation > taskConstruction); + + String commandSource = source("art/arcane/iris/modded/command/ModdedPregenCommands.java"); + String command = method(commandSource, "static int pregenStart("); + assertTrue(command.contains("catch (IllegalArgumentException failure)")); + assertTrue(command.contains("IrisModdedCommands.fail(source, failure.getMessage())")); + } + private static String source(String relativePath) throws IOException { String root = System.getProperty(SOURCE_ROOT_PROPERTY); assertTrue("Missing system property " + SOURCE_ROOT_PROPERTY, root != null && !root.isBlank()); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructurePoiRegistrationTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructurePoiRegistrationTest.java new file mode 100644 index 000000000..6b371d9b3 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructurePoiRegistrationTest.java @@ -0,0 +1,169 @@ +package art.arcane.iris.modded; + +import com.mojang.serialization.Codec; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.IdMapper; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.entity.ai.village.poi.PoiSection; +import net.minecraft.world.entity.ai.village.poi.PoiType; +import net.minecraft.world.entity.ai.village.poi.PoiTypes; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeGenerationSettings; +import net.minecraft.world.level.biome.BiomeSpecialEffects; +import net.minecraft.world.level.biome.MobSpawnSettings; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.PalettedContainer; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.PalettedContainerRO; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.Strategy; +import net.minecraft.world.level.chunk.UpgradeData; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ModdedNativeStructurePoiRegistrationTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void visitsOnlyExistingPoiBlocksBeforeNativePlacement() { + ProtoChunk chunk = newChunk(); + BlockPos poiPosition = new BlockPos(3, 70, 5); + BlockPos ordinaryPosition = new BlockPos(4, 70, 5); + BlockState poiState = Blocks.BARREL.defaultBlockState(); + chunk.setBlockState(poiPosition, poiState, 0); + chunk.setBlockState(ordinaryPosition, Blocks.STONE.defaultBlockState(), 0); + Map visited = new LinkedHashMap<>(); + + ModdedNativeStructureStage.visitExistingPois( + chunk, (position, state) -> visited.put(position.immutable(), state)); + + assertEquals(poiState, visited.get(poiPosition)); + assertFalse(visited.containsKey(ordinaryPosition)); + assertEquals(1, visited.size()); + } + + @Test + public void duplicatePrimingIsIdempotentForAnAlreadyRegisteredPoi() { + BlockPos position = new BlockPos(3, 70, 5); + Holder type = PoiTypes.forState( + Blocks.BARREL.defaultBlockState()).orElseThrow(); + PoiSection section = new PoiSection(() -> { }); + + assertNotNull(section.add(position, type)); + assertNull(section.add(position, type)); + assertEquals(type, section.getType(position).orElseThrow()); + + section.remove(position); + + assertTrue(section.getType(position).isEmpty()); + } + + @Test + public void worldgenPrimingQueuesBeforeTheLaterVanillaRemoval() { + ProtoChunk chunk = newChunk(); + BlockPos position = new BlockPos(3, 70, 5); + BlockState poiState = Blocks.BARREL.defaultBlockState(); + chunk.setBlockState(position, poiState, 0); + PoiSection section = new PoiSection(() -> { }); + Deque serverQueue = new ArrayDeque<>(); + AtomicInteger missingRemovals = new AtomicInteger(); + + ModdedNativeStructureStage.visitExistingPois(chunk, + (poiPosition, state) -> queuePoiTransition( + serverQueue, section, poiPosition, + Blocks.AIR.defaultBlockState(), state, missingRemovals)); + assertTrue(section.getType(position).isEmpty()); + queuePoiTransition(serverQueue, section, position, + poiState, Blocks.AIR.defaultBlockState(), missingRemovals); + + while (!serverQueue.isEmpty()) { + serverQueue.removeFirst().run(); + } + + assertEquals(0, missingRemovals.get()); + assertTrue(section.getType(position).isEmpty()); + } + + private static void queuePoiTransition(Deque serverQueue, + PoiSection section, + BlockPos position, + BlockState oldState, + BlockState newState, + AtomicInteger missingRemovals) { + Optional> oldType = PoiTypes.forState(oldState); + Optional> newType = PoiTypes.forState(newState); + if (Objects.equals(oldType, newType)) { + return; + } + BlockPos immutablePosition = position.immutable(); + oldType.ifPresent(type -> serverQueue.addLast(() -> { + if (section.getType(immutablePosition).isEmpty()) { + missingRemovals.incrementAndGet(); + return; + } + section.remove(immutablePosition); + })); + newType.ifPresent(type -> serverQueue.addLast( + () -> section.add(immutablePosition, type))); + } + + private static ProtoChunk newChunk() { + return new ProtoChunk( + new ChunkPos(0, 0), + UpgradeData.EMPTY, + LevelHeightAccessor.create(-64, 384), + palettedContainerFactory(), + null); + } + + private static PalettedContainerFactory palettedContainerFactory() { + Strategy blockStrategy = Strategy.createForBlockStates(Block.BLOCK_STATE_REGISTRY); + Codec> blockCodec = PalettedContainer.codecRW( + BlockState.CODEC, blockStrategy, Blocks.AIR.defaultBlockState()); + Biome biome = new Biome.BiomeBuilder() + .hasPrecipitation(false) + .temperature(0.8F) + .downfall(0.4F) + .specialEffects(new BiomeSpecialEffects.Builder().waterColor(0x3F76E4).build()) + .mobSpawnSettings(MobSpawnSettings.EMPTY) + .generationSettings(BiomeGenerationSettings.EMPTY) + .build(); + Holder biomeHolder = Holder.direct(biome); + IdMapper> biomeIds = new IdMapper<>(1); + biomeIds.add(biomeHolder); + Strategy> biomeStrategy = Strategy.createForBiomes(biomeIds); + Codec>> biomeCodec = PalettedContainer.codecRO( + Biome.CODEC, biomeStrategy, biomeHolder); + return new PalettedContainerFactory( + blockStrategy, + Blocks.AIR.defaultBlockState(), + blockCodec, + biomeStrategy, + biomeHolder, + biomeCodec); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java new file mode 100644 index 000000000..e231736e9 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedNativeStructureWorldgenAccessTest.java @@ -0,0 +1,323 @@ +package art.arcane.iris.modded; + +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.core.Holder; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeManager; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.entity.EntityTypeTest; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.material.Fluid; +import net.minecraft.world.level.material.FluidState; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import net.minecraft.world.ticks.LevelTickAccess; +import net.minecraft.world.ticks.ScheduledTick; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class ModdedNativeStructureWorldgenAccessTest { + private static final int GENERATION_CENTER_X = 60; + private static final int GENERATION_CENTER_Z = 15; + private static final int SURFACE_FIRST_FREE_Y = 80; + private static final int FLOOR_FIRST_FREE_Y = 70; + + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void distanceTwoTerrainReadsNeverReachTheDelegate() { + RecordingDelegate recording = new RecordingDelegate(); + ModdedNativeStructureWorldgenAccess access = access(recording); + ChunkPos generationCenter = generationCenter(); + int x = generationCenter.getMiddleBlockX(); + int z = (generationCenter.z() + 2) << 4; + + assertSame(Blocks.STONE, access.getBlockState(new BlockPos(x, FLOOR_FIRST_FREE_Y - 1, z)).getBlock()); + assertSame(Blocks.WATER, access.getBlockState(new BlockPos(x, FLOOR_FIRST_FREE_Y, z)).getBlock()); + assertSame(Blocks.AIR, access.getBlockState(new BlockPos(x, SURFACE_FIRST_FREE_Y, z)).getBlock()); + assertSame(Blocks.WATER.defaultBlockState().getFluidState(), + access.getFluidState(new BlockPos(x, FLOOR_FIRST_FREE_Y, z))); + assertEquals(SURFACE_FIRST_FREE_Y, access.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z)); + assertEquals(FLOOR_FIRST_FREE_Y, access.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, x, z)); + assertNull(access.getChunk(generationCenter.x(), generationCenter.z() + 2, ChunkStatus.FEATURES, false)); + List loadedOnly = access.getBlockStatesIfLoaded(new AABB( + x, FLOOR_FIRST_FREE_Y, z, x, FLOOR_FIRST_FREE_Y, z)).toList(); + List streamed = access.getBlockStates(new AABB( + x, FLOOR_FIRST_FREE_Y, z, x, FLOOR_FIRST_FREE_Y, z)).toList(); + + assertTrue(loadedOnly.isEmpty()); + assertEquals(1, streamed.size()); + assertSame(Blocks.WATER, streamed.getFirst().getBlock()); + assertEquals(0, recording.terrainReads); + assertEquals(0, recording.heightReads); + assertEquals(0, recording.chunkReads); + } + + @Test + public void distanceTwoMutationsAndSideEffectsNeverReachTheDelegate() { + RecordingDelegate recording = new RecordingDelegate(); + ModdedNativeStructureWorldgenAccess access = access(recording); + ChunkPos generationCenter = generationCenter(); + BlockPos position = new BlockPos( + generationCenter.getMiddleBlockX(), + FLOOR_FIRST_FREE_Y, + (generationCenter.z() + 2) << 4); + + assertFalse(access.ensureCanWrite(position)); + assertFalse(access.setBlock(position, Blocks.DIRT.defaultBlockState(), 2)); + assertFalse(access.removeBlock(position, false)); + assertFalse(access.destroyBlock(position, false)); + access.updateNeighborsAt(position, Blocks.DIRT); + access.neighborShapeChanged( + Direction.NORTH, position, position.north(), + Blocks.DIRT.defaultBlockState(), 2, 512); + access.levelEvent(null, 2001, position, 0); + access.gameEvent(null, Vec3.atCenterOf(position), null); + access.addParticle(null, position.getX(), position.getY(), position.getZ(), 0, 0, 0); + access.getBlockTicks().schedule(new ScheduledTick<>( + Blocks.DIRT, position, 1L, 0L)); + + assertEquals(0, recording.mutations); + assertEquals(0, recording.events); + assertEquals(0, recording.blockTicks.count()); + } + + @Test + public void radiusOneReadsWritesAndTicksRemainUnchanged() { + RecordingDelegate recording = new RecordingDelegate(); + ModdedNativeStructureWorldgenAccess access = access(recording); + ChunkPos generationCenter = generationCenter(); + BlockPos position = new BlockPos( + (generationCenter.x() + 1) << 4, + FLOOR_FIRST_FREE_Y, + (generationCenter.z() - 1) << 4); + + assertTrue(access.isInsideGenerationRegion( + generationCenter.x() + 1, generationCenter.z() - 1)); + assertSame(Blocks.DIRT, access.getBlockState(position).getBlock()); + assertEquals(91, access.getHeight( + Heightmap.Types.WORLD_SURFACE_WG, position.getX(), position.getZ())); + assertTrue(access.ensureCanWrite(position)); + assertTrue(access.setBlock(position, Blocks.STONE.defaultBlockState(), 2)); + assertTrue(access.removeBlock(position, false)); + assertTrue(access.destroyBlock(position, false)); + access.getBlockTicks().schedule(new ScheduledTick<>( + Blocks.DIRT, position, 1L, 0L)); + + assertEquals(1, recording.terrainReads); + assertEquals(1, recording.heightReads); + assertEquals(4, recording.mutations); + assertEquals(1, recording.blockTicks.count()); + } + + @Test + public void statuslessChunkReadsPreserveWorldgenDelegateSemantics() { + RecordingDelegate recording = new RecordingDelegate(); + ModdedNativeStructureWorldgenAccess access = access(recording); + ChunkPos generationCenter = generationCenter(); + + assertNull(access.getChunk(generationCenter.getWorldPosition())); + + assertEquals(1, recording.statuslessChunkReads); + assertEquals(0, recording.statusChunkReads); + } + + @Test + public void entityQueriesRejectDisjointAreasAndClampOverlaps() { + RecordingDelegate recording = new RecordingDelegate(); + ModdedNativeStructureWorldgenAccess access = access(recording); + ChunkPos generationCenter = generationCenter(); + int safeMaxX = generationCenter.getMaxBlockX() + 17; + int safeMinZ = generationCenter.getMinBlockZ() - 16; + int safeMaxZ = generationCenter.getMaxBlockZ() + 17; + AABB disjoint = new AABB( + safeMaxX, -64, safeMinZ, + safeMaxX + 16, 320, safeMaxZ); + AABB overlap = new AABB( + safeMaxX - 8, -128, safeMinZ - 8, + safeMaxX + 16, 400, safeMaxZ + 8); + EntityTypeTest type = EntityTypeTest.forClass(Entity.class); + + assertTrue(access.getEntities((Entity) null, disjoint, entity -> true).isEmpty()); + assertTrue(access.getEntities(type, disjoint, entity -> true).isEmpty()); + access.getEntities((Entity) null, overlap, entity -> true); + access.getEntities(type, overlap, entity -> true); + + assertEquals(2, recording.entityAreas.size()); + for (AABB delegatedArea : recording.entityAreas) { + assertEquals(safeMaxX - 8, delegatedArea.minX, 0D); + assertEquals(-64, delegatedArea.minY, 0D); + assertEquals(safeMinZ, delegatedArea.minZ, 0D); + assertEquals(safeMaxX, delegatedArea.maxX, 0D); + assertEquals(320, delegatedArea.maxY, 0D); + assertEquals(safeMaxZ, delegatedArea.maxZ, 0D); + } + } + + private static ModdedNativeStructureWorldgenAccess access(RecordingDelegate recording) { + return ModdedNativeStructureWorldgenAccess.create( + recording.world(), generationCenter(), + (x, z) -> SURFACE_FIRST_FREE_Y, + (x, z) -> FLOOR_FIRST_FREE_Y); + } + + private static ChunkPos generationCenter() { + return new ChunkPos(GENERATION_CENTER_X, GENERATION_CENTER_Z); + } + + private static final class RecordingDelegate implements InvocationHandler { + private final Holder biome; + private final BiomeManager biomeManager; + private final RecordingTicks blockTicks; + private final RecordingTicks fluidTicks; + private final List entityAreas; + private int terrainReads; + private int heightReads; + private int chunkReads; + private int statuslessChunkReads; + private int statusChunkReads; + private int mutations; + private int events; + + private RecordingDelegate() { + this.biome = Holder.direct((Biome) null); + this.biomeManager = new BiomeManager((x, y, z) -> biome, 13L); + this.blockTicks = new RecordingTicks<>(); + this.fluidTicks = new RecordingTicks<>(); + this.entityAreas = new ArrayList<>(); + } + + private WorldGenLevel world() { + return (WorldGenLevel) Proxy.newProxyInstance( + WorldGenLevel.class.getClassLoader(), + new Class[]{WorldGenLevel.class}, this); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] arguments) { + String name = method.getName(); + if (name.equals("getSeaLevel")) { + return 63; + } + if (name.equals("getBiome")) { + return biome; + } + if (name.equals("getBiomeManager")) { + return biomeManager; + } + if (name.equals("getBlockTicks")) { + return blockTicks; + } + if (name.equals("getFluidTicks")) { + return fluidTicks; + } + if (name.equals("getMinY")) { + return -64; + } + if (name.equals("getHeight") && arguments == null) { + return 384; + } + if (name.equals("getHeight")) { + heightReads++; + return 91; + } + if (name.equals("getBlockState")) { + terrainReads++; + return Blocks.DIRT.defaultBlockState(); + } + if (name.equals("getFluidState")) { + terrainReads++; + return Blocks.WATER.defaultBlockState().getFluidState(); + } + if (name.equals("getChunk")) { + chunkReads++; + if (arguments.length == 2) { + statuslessChunkReads++; + } else { + statusChunkReads++; + } + return null; + } + if (name.equals("getChunkIfLoadedImmediately")) { + chunkReads++; + return null; + } + if (name.equals("getEntities")) { + entityAreas.add((AABB) arguments[1]); + return List.of(); + } + if (name.equals("ensureCanWrite") || name.equals("setBlock") + || name.equals("removeBlock") || name.equals("destroyBlock")) { + mutations++; + return true; + } + if (name.equals("updateNeighborsAt") || name.equals("neighborShapeChanged")) { + mutations++; + return null; + } + if (name.equals("levelEvent") || name.equals("gameEvent") || name.equals("addParticle")) { + events++; + return null; + } + if (name.equals("hashCode")) { + return System.identityHashCode(proxy); + } + if (name.equals("equals")) { + return proxy == arguments[0]; + } + if (name.equals("toString")) { + return "native-structure-worldgen-test-delegate"; + } + throw new UnsupportedOperationException(method.toString()); + } + } + + private static final class RecordingTicks implements LevelTickAccess { + private int count; + + @Override + public void schedule(ScheduledTick tick) { + count++; + } + + @Override + public boolean hasScheduledTick(BlockPos position, T type) { + return false; + } + + @Override + public int count() { + return count; + } + + @Override + public boolean willTickThisTick(BlockPos position, T type) { + return false; + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureTemplateCacheTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureTemplateCacheTest.java new file mode 100644 index 000000000..edbedc22d --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureTemplateCacheTest.java @@ -0,0 +1,77 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +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 java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class ModdedStructureTemplateCacheTest { + @Test + public void concurrentCacheOwnsDistinctAndSharedMappingsExactlyOnce() throws Exception { + ConcurrentHashMap cache = new ConcurrentHashMap<>(); + ExecutorService executor = Executors.newFixedThreadPool(32); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger mappingCalls = new AtomicInteger(); + List> futures = new ArrayList<>(); + try { + for (int task = 0; task < 256; task++) { + int value = task; + String key = task % 2 == 0 ? "shared" : "distinct-" + task; + futures.add(executor.submit(() -> { + start.await(); + return cache.computeIfAbsent(key, ignored -> { + mappingCalls.incrementAndGet(); + Thread.yield(); + return value; + }); + })); + } + start.countDown(); + for (Future future : futures) { + assertNotNull(future.get(10, TimeUnit.SECONDS)); + } + } finally { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); + } + + assertEquals(129, cache.size()); + assertEquals(129, mappingCalls.get()); + } + + @Test + public void requiredCommonMixinConfigRegistersPaletteConcurrencyFix() throws Exception { + InputStream resource = ModdedStructureTemplateCacheTest.class.getClassLoader() + .getResourceAsStream("irisworldgen.entity.mixins.json"); + assertNotNull(resource); + String config; + try (InputStream input = resource) { + config = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + assertTrue(config.contains("\"StructureTemplatePaletteConcurrencyMixin\"")); + + Path source = Path.of(System.getProperty("iris.moddedCommonSources")) + .resolve("art/arcane/iris/modded/mixin/StructureTemplatePaletteConcurrencyMixin.java"); + String mixin = Files.readString(source); + assertTrue(mixin.contains("@Mixin(StructureTemplate.Palette.class)")); + assertTrue(mixin.contains("@Shadow\n @Final\n @Mutable")); + assertTrue(mixin.contains("private Map> cache;")); + assertTrue(mixin.contains("@Inject(method = \"(Ljava/util/List;)V\", at = @At(\"RETURN\"))")); + assertTrue(mixin.contains("cache = new ConcurrentHashMap<>();")); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java index b23a4fab2..7ee5f5f67 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/NativeStructureFailureContractTest.java @@ -67,6 +67,11 @@ public class NativeStructureFailureContractTest { assertTrue(placement.contains("\"terrain preparation\"")); assertTrue(placement.contains("\"foundation repair\"")); assertFalse(placement.contains("\"terrain carving\"")); + assertTrue(placement.contains("visitExistingPois(chunk")); + assertTrue(placement.contains("level.updatePOIOnBlockStateChange(")); + assertTrue(placement.contains("Blocks.AIR.defaultBlockState(), state")); + assertTrue(placement.indexOf("visitExistingPois(chunk") + < placement.indexOf("WorldgenTerrainHeightmaps.primeStructurePlacement(")); assertTrue(placement.contains("prepareSurfaceStructures")); assertTrue(placement.contains("clearIntersectingVegetation")); assertTrue(placement.indexOf("clearIntersectingVegetation") @@ -95,6 +100,19 @@ public class NativeStructureFailureContractTest { assertTrue(source.contains("int minY = chunk.getMinY() + 1;")); } + @Test + public void nativeStructurePostProcessingUsesBoundedWorldgenAccess() throws IOException { + String source = moddedSource("ModdedNativeStructureStage.java"); + int placementStart = source.indexOf("private void placeVanillaStructure"); + int placementEnd = source.indexOf("private List> structuresByStep", placementStart); + String placement = source.substring(placementStart, placementEnd); + + assertTrue(placement.contains("ModdedNativeStructureWorldgenAccess.create(")); + assertTrue(placement.contains("NativeStructurePostProcessor.place(\n boundedWorld")); + assertFalse(placement.contains("NativeStructurePostProcessor.place(world,")); + assertTrue(placement.contains("world.setCurrentlyGenerating(null);")); + } + @Test public void structureFailurePreservesPhaseIdentityChunkAndCause() { IllegalArgumentException cause = new IllegalArgumentException("broken placement"); diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index eab87581e..a10978606 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -30,8 +30,8 @@ Properties rootProperties = new Properties() file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.0.0-26.2')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2')) -String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.2.0.12-beta')) -String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969')) +String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.2.0.59')) +String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')) Closure irisArtifactName = { String platform, String targetVersion -> return "Iris v${project.version} [${platform}] ${targetVersion}.jar" } diff --git a/build.gradle b/build.gradle index 38cbcc792..b6a66ccf4 100644 --- a/build.gradle +++ b/build.gradle @@ -51,10 +51,10 @@ String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse // The Bukkit jar runs Paper 26.1.2 through 26.2 (compile-low pins); the loader jars stay 26.2-only. String bukkitMinecraftRange = providers.gradleProperty('bukkitMinecraftRange').getOrElse('26.1.2-26.2') String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse('0.19.3') -String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.2-65.0.4') -String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.2.0.12-beta') +String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.2-65.1.1') +String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.2.0.59') String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') - .orElse('com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969') + .orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522') .get() String useLocalVolmLib = providers.gradleProperty('useLocalVolmLib').getOrElse('true') String localVolmLibDirectory = providers.gradleProperty('localVolmLibDirectory').getOrNull() diff --git a/core/build.gradle b/core/build.gradle index 5eb79db79..cd6567898 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -37,7 +37,7 @@ plugins { def lib = 'art.arcane.iris.util' String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') - .orElse('com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969') + .orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522') .get() String sentryAuthToken = findProperty('sentry.auth.token') as String ?: System.getenv('SENTRY_AUTH_TOKEN') boolean hasSentryAuthToken = sentryAuthToken != null && !sentryAuthToken.isBlank() diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java b/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java index 25d9bf36d..ee5e61b9c 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java +++ b/core/src/main/java/art/arcane/iris/core/IrisWorldStorage.java @@ -289,6 +289,19 @@ public final class IrisWorldStorage { return requireSafeManagedDimensionRoot(levelRoot(), key); } + public static File requireSafePersistentDimensionRoot(NamespacedKey key) { + return requireSafePersistentDimensionRoot(levelRoot(), key); + } + + static File requireSafePersistentDimensionRoot(File levelRoot, NamespacedKey key) { + NamespacedKey worldKey = Objects.requireNonNull(key, "key"); + WorldSlotKey slotKey = new WorldSlotKey(worldKey.getNamespace(), worldKey.getKey()); + return ExactWorldSlotPathPolicy.resolve( + Objects.requireNonNull(levelRoot, "levelRoot").toPath(), + slotKey + ).worldDirectory().toFile(); + } + 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_-]+")) { @@ -368,4 +381,131 @@ public final class IrisWorldStorage { public static File packRoot(NamespacedKey key) { return new File(dimensionRoot(key), "iris/pack"); } + + public static File requireFrozenDimensionRoot( + File worldContainer, + File levelRoot, + String bukkitWorldName, + NamespacedKey key + ) { + NamespacedKey worldKey = Objects.requireNonNull(key, "key"); + return frozenDimensionRoot(worldContainer, levelRoot, bukkitWorldName, worldKey) + .orElseThrow(() -> new IllegalStateException( + "Frozen Iris world storage is missing for " + worldKey + ".")); + } + + public static Optional frozenDimensionRoot( + File worldContainer, + File levelRoot, + String bukkitWorldName, + NamespacedKey key + ) { + File requiredLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").getAbsoluteFile(); + NamespacedKey worldKey = Objects.requireNonNull(key, "key"); + String requiredWorldName = Objects.requireNonNull(bukkitWorldName, "bukkitWorldName").trim(); + if (requiredWorldName.isEmpty()) { + throw new IllegalArgumentException("Bukkit world name cannot be empty."); + } + + File directRoot = dimensionRoot(requiredLevelRoot, worldKey); + boolean directExists = isExistingSafeDimensionRoot(requiredLevelRoot.toPath(), directRoot.toPath()); + if (!IRIS_NAMESPACE.equals(worldKey.getNamespace())) { + return directExists ? Optional.of(directRoot) : Optional.empty(); + } + + String configuredName = configuredWorldName(worldKey, requiredLevelRoot.getName()); + if (!configuredName.equals(requiredWorldName)) { + return directExists ? Optional.of(directRoot) : Optional.empty(); + } + + File configuredRoot = configuredDimensionRoot(worldContainer, requiredLevelRoot, worldKey); + boolean configuredExists = isExistingSafeDimensionRoot( + Objects.requireNonNull(worldContainer, "worldContainer").toPath(), + configuredRoot.toPath() + ); + if (directExists == configuredExists) { + if (directExists) { + throw new IllegalStateException("Frozen Iris world storage is ambiguous for " + worldKey + "."); + } + return Optional.empty(); + } + return Optional.of(directExists ? directRoot : configuredRoot); + } + + public static File configuredLevelRoot(File worldContainer, File levelRoot, NamespacedKey key) { + Path container = Objects.requireNonNull(worldContainer, "worldContainer") + .toPath() + .toAbsolutePath() + .normalize(); + String configuredName = configuredWorldName( + Objects.requireNonNull(key, "key"), + Objects.requireNonNull(levelRoot, "levelRoot").getName() + ); + Path configuredLevelRoot = container.resolve(configuredName).normalize(); + if (!Objects.equals(configuredLevelRoot.getParent(), container)) { + throw new IllegalStateException("Configured Bukkit world storage escapes the world container."); + } + isExistingSafeDimensionRoot(container, configuredLevelRoot.resolve("dimensions")); + return configuredLevelRoot.toFile(); + } + + public static File configuredDimensionRoot(File worldContainer, File levelRoot, NamespacedKey key) { + File configuredLevelRoot = configuredLevelRoot(worldContainer, levelRoot, key); + File configuredRoot = dimensionRoot(configuredLevelRoot, key); + isExistingSafeDimensionRoot( + Objects.requireNonNull(worldContainer, "worldContainer").toPath(), + configuredRoot.toPath() + ); + return configuredRoot; + } + + public static File requireFrozenPackRoot(File dimensionRoot) { + Path root = Objects.requireNonNull(dimensionRoot, "dimensionRoot") + .toPath() + .toAbsolutePath() + .normalize(); + Path irisRoot = root.resolve("iris"); + Path packRoot = irisRoot.resolve("pack"); + for (Path path : new Path[]{root, irisRoot, packRoot}) { + if (Files.isSymbolicLink(path)) { + throw new IllegalStateException("Frozen Iris pack path contains a symbolic link: " + path); + } + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS) + && !Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException("Frozen Iris pack path is not a directory: " + path); + } + } + if (!Files.isDirectory(packRoot, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException("Frozen Iris pack snapshot is missing: " + packRoot); + } + return packRoot.toFile(); + } + + private static boolean isExistingSafeDimensionRoot(Path storageRoot, Path dimensionRoot) { + Path root = storageRoot.toAbsolutePath().normalize(); + Path target = dimensionRoot.toAbsolutePath().normalize(); + if (Files.isSymbolicLink(root)) { + throw new IllegalStateException("Iris world storage root is a symbolic link: " + root); + } + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException("Iris world storage root is not a directory: " + root); + } + if (!target.startsWith(root) || Objects.equals(target, root)) { + throw new IllegalStateException("Iris world storage escapes its expected root: " + target); + } + + Path relative = root.relativize(target); + Path current = root; + for (Path segment : relative) { + current = current.resolve(segment); + if (Files.isSymbolicLink(current)) { + throw new IllegalStateException("Iris world storage contains a symbolic link: " + current); + } + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS) + && !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException("Iris world storage path is not a directory: " + current); + } + } + return Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS); + } } diff --git a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java index 0f1ab8cde..48504a39e 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisWorlds.java +++ b/core/src/main/java/art/arcane/iris/core/IrisWorlds.java @@ -26,12 +26,12 @@ import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; -import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.stream.Stream; public class IrisWorlds { @@ -147,8 +147,9 @@ public class IrisWorlds { public synchronized void clean() { boolean removed = worlds.entrySet().removeIf(entry -> { try { - File packRoot = packRoot(entry.getKey()); - return !new File(packRoot, "dimensions/" + entry.getValue() + ".json").exists(); + Optional packRoot = packRoot(entry.getKey()); + return packRoot.isEmpty() + || !new File(packRoot.get(), "dimensions/" + entry.getValue() + ".json").exists(); } catch (IllegalArgumentException e) { return true; } @@ -236,12 +237,20 @@ public class IrisWorlds { .equals(configuredWorldName)) { continue; } - Path dimensionRoot = IrisWorldStorage.dimensionRoot(root.toFile(), worldKey) - .toPath() - .toAbsolutePath() - .normalize(); - if (Files.isDirectory(dimensionRoot, LinkOption.NOFOLLOW_LINKS)) { - result.put(configuredWorldName, entry.getValue()); + Path worldContainer = root.getParent(); + if (worldContainer == null) { + throw new IllegalArgumentException("Selected level root has no world container: " + root); + } + try { + if (IrisWorldStorage.frozenDimensionRoot( + worldContainer.toFile(), + root.toFile(), + configuredWorldName, + worldKey + ).isPresent()) { + result.put(configuredWorldName, entry.getValue()); + } + } catch (IllegalStateException ignored) { } } return result; @@ -270,14 +279,28 @@ public class IrisWorlds { return filterBukkitWorldsByStorage(levelRoot, result); } - private File packRoot(String worldIdentity) { + private Optional packRoot(String worldIdentity) { NamespacedKey worldKey = WorldIdentity.parse(worldIdentity); - return new File(IrisWorldStorage.dimensionRoot(levelRoot.toFile(), worldKey), "iris/pack"); + Path worldContainer = levelRoot.getParent(); + if (worldContainer == null) { + throw new IllegalStateException("Selected level root has no world container: " + levelRoot); + } + String configuredWorldName = IrisWorldStorage.configuredWorldName( + worldKey, + levelRoot.getFileName().toString() + ); + Optional dimensionRoot = IrisWorldStorage.frozenDimensionRoot( + worldContainer.toFile(), + levelRoot.toFile(), + configuredWorldName, + worldKey + ); + return dimensionRoot.map(IrisWorldStorage::requireFrozenPackRoot); } private IrisDimension loadDimension(String worldIdentity, String id) { - File pack = packRoot(worldIdentity); - IrisDimension dimension = pack.isDirectory() ? IrisData.get(pack).getDimensionLoader().load(id) : null; + File pack = packRoot(worldIdentity).orElse(null); + IrisDimension dimension = pack == null ? null : IrisData.get(pack).getDimensionLoader().load(id); if (dimension == null) { dimension = IrisData.loadAnyDimension(id, null); } diff --git a/core/src/main/java/art/arcane/iris/core/WorldCreatorCompat.java b/core/src/main/java/art/arcane/iris/core/WorldCreatorCompat.java index f6f4cf4e1..84fe82563 100644 --- a/core/src/main/java/art/arcane/iris/core/WorldCreatorCompat.java +++ b/core/src/main/java/art/arcane/iris/core/WorldCreatorCompat.java @@ -1,13 +1,17 @@ package art.arcane.iris.core; +import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; import org.bukkit.WorldCreator; +import java.io.File; + /** * WorldCreator.ofKey and WorldCreator#key are Paper-API-only. Once a call throws * NoSuchMethodError (plain Spigot/CraftBukkit) this flips and every later call goes * straight to the fallback. The fallback derives names/keys through IrisWorldStorage's - * logical mapping so keyFromName(creator.name()) round-trips on Spigot. + * current configured-name mapping so persistent Spigot worlds round-trip without changing + * their startup directory. */ public final class WorldCreatorCompat { private static volatile boolean keyedCreatorsUnavailable; @@ -16,14 +20,45 @@ public final class WorldCreatorCompat { } public static WorldCreator ofKey(NamespacedKey worldKey) { - if (!keyedCreatorsUnavailable) { - try { - return WorldCreator.ofKey(worldKey); - } catch (NoSuchMethodError e) { - keyedCreatorsUnavailable = true; - } + return ofKey(worldKey, IrisWorldStorage.logicalName(worldKey)); + } + + public static WorldCreator ofKey(NamespacedKey worldKey, String fallbackWorldName) { + WorldCreator keyedCreator = keyedCreator(worldKey); + if (keyedCreator != null) { + return keyedCreator; } - return new WorldCreator(IrisWorldStorage.logicalName(worldKey)); + return new WorldCreator(fallbackWorldName); + } + + public static WorldCreator ofPersistentKey(NamespacedKey worldKey) { + WorldCreator keyedCreator = keyedCreator(worldKey); + if (keyedCreator != null) { + return keyedCreator; + } + return new WorldCreator(fallbackPersistentName(worldKey, IrisWorldStorage.levelRoot().getName())); + } + + public static File persistentDimensionRoot(NamespacedKey worldKey) { + if (keyedCreator(worldKey) != null) { + return IrisWorldStorage.requireSafePersistentDimensionRoot(worldKey); + } + return IrisWorldStorage.configuredDimensionRoot( + Bukkit.getWorldContainer(), + IrisWorldStorage.levelRoot(), + worldKey + ); + } + + public static File persistentLevelRoot(NamespacedKey worldKey) { + if (keyedCreator(worldKey) != null) { + return persistentDimensionRoot(worldKey); + } + return IrisWorldStorage.configuredLevelRoot( + Bukkit.getWorldContainer(), + IrisWorldStorage.levelRoot(), + worldKey + ); } public static NamespacedKey keyOf(WorldCreator creator) { @@ -34,14 +69,33 @@ public final class WorldCreatorCompat { keyedCreatorsUnavailable = true; } } - return IrisWorldStorage.keyFromName(creator.name()); + return IrisWorldStorage.keyFromConfiguredWorldName( + creator.name(), + IrisWorldStorage.levelRoot().getName() + ); } static String fallbackName(NamespacedKey worldKey, String levelName) { return IrisWorldStorage.logicalName(worldKey, levelName); } + static String fallbackPersistentName(NamespacedKey worldKey, String levelName) { + return IrisWorldStorage.configuredWorldName(worldKey, levelName); + } + static NamespacedKey fallbackKey(String creatorName, String levelName) { - return IrisWorldStorage.keyFromName(creatorName, levelName); + return IrisWorldStorage.keyFromConfiguredWorldName(creatorName, levelName); + } + + private static WorldCreator keyedCreator(NamespacedKey worldKey) { + if (keyedCreatorsUnavailable) { + return null; + } + try { + return WorldCreator.ofKey(worldKey); + } catch (NoSuchMethodError e) { + keyedCreatorsUnavailable = true; + return null; + } } } diff --git a/core/src/main/java/art/arcane/iris/core/WorldRemovalPathPolicy.java b/core/src/main/java/art/arcane/iris/core/WorldRemovalPathPolicy.java index 30b81b055..570e674f0 100644 --- a/core/src/main/java/art/arcane/iris/core/WorldRemovalPathPolicy.java +++ b/core/src/main/java/art/arcane/iris/core/WorldRemovalPathPolicy.java @@ -50,22 +50,21 @@ public final class WorldRemovalPathPolicy { throw new Rejection(RejectionReason.SYMBOLIC_LINK, "World storage path contains a symbolic link: " + normalizedLevelRoot); } - Path target; + StorageLayout storageLayout; try { - target = IrisWorldStorage.requireSafeManagedDimensionRoot( - normalizedLevelRoot.toFile(), - worldKey - ).toPath().toAbsolutePath().normalize(); - } catch (IllegalArgumentException failure) { + storageLayout = resolveStorageLayout(normalizedLevelRoot, worldKey); + } catch (RuntimeException failure) { throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure); } - validateStoragePath(normalizedLevelRoot, worldKey, target); + validateStoragePath(normalizedLevelRoot, worldKey, storageLayout.dimensionDirectory()); + validateStorageRoot(normalizedLevelRoot, worldKey, storageLayout.storageDirectory()); return new Target( requestedIdentifier, worldKey, IrisWorldStorage.logicalName(worldKey, mainWorld), normalizedLevelRoot, - target + storageLayout.dimensionDirectory(), + storageLayout.storageDirectory() ); } @@ -77,11 +76,11 @@ public final class WorldRemovalPathPolicy { } Path expected; try { - expected = IrisWorldStorage.requireSafeManagedDimensionRoot( - normalizedLevelRoot.toFile(), + expected = resolveStorageLayout( + normalizedLevelRoot, Objects.requireNonNull(worldKey, "worldKey") - ).toPath().toAbsolutePath().normalize(); - } catch (IllegalArgumentException failure) { + ).dimensionDirectory(); + } catch (RuntimeException failure) { throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure); } Path normalizedCandidate = Objects.requireNonNull(candidate, "candidate").toAbsolutePath().normalize(); @@ -91,6 +90,28 @@ public final class WorldRemovalPathPolicy { } } + public static void validateStorageRoot(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 = resolveStorageLayout( + normalizedLevelRoot, + Objects.requireNonNull(worldKey, "worldKey") + ).storageDirectory(); + } catch (RuntimeException 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 storage directory is outside its exact current platform root."); + } + } + private static Rejection classifyIdentifierFailure(String identifier, IllegalArgumentException failure) { NamespacedKey parsed = identifier.contains(":") ? NamespacedKey.fromString(identifier.toLowerCase(Locale.ENGLISH)) @@ -104,12 +125,14 @@ public final class WorldRemovalPathPolicy { private static Rejection classifyStorageFailure( Path levelRoot, NamespacedKey worldKey, - IllegalArgumentException failure + RuntimeException failure ) { Path dimensions = levelRoot.resolve("dimensions"); Path namespace = dimensions.resolve(worldKey.getNamespace()); Path target = namespace.resolve(worldKey.getKey()); - RejectionReason reason = Files.isSymbolicLink(dimensions) + String failureMessage = String.valueOf(failure.getMessage()).toLowerCase(Locale.ENGLISH); + RejectionReason reason = failureMessage.contains("symbolic link") + || Files.isSymbolicLink(dimensions) || Files.isSymbolicLink(namespace) || Files.isSymbolicLink(target) ? RejectionReason.SYMBOLIC_LINK @@ -117,6 +140,45 @@ public final class WorldRemovalPathPolicy { return new Rejection(reason, failure.getMessage(), failure); } + private static StorageLayout resolveStorageLayout(Path levelRoot, NamespacedKey worldKey) { + Path worldContainer = levelRoot.getParent(); + if (worldContainer == null) { + throw new IllegalArgumentException("Selected level root has no world container: " + levelRoot); + } + String configuredWorldName = IrisWorldStorage.configuredWorldName( + worldKey, + levelRoot.getFileName().toString() + ); + Path directDimension = IrisWorldStorage.requireSafeManagedDimensionRoot( + levelRoot.toFile(), + worldKey + ).toPath().toAbsolutePath().normalize(); + Path dimensionDirectory = IrisWorldStorage.frozenDimensionRoot( + worldContainer.toFile(), + levelRoot.toFile(), + configuredWorldName, + worldKey + ).map(file -> file.toPath().toAbsolutePath().normalize()).orElse(directDimension); + if (dimensionDirectory.equals(directDimension)) { + return new StorageLayout(directDimension, directDimension); + } + + Path configuredDimension = IrisWorldStorage.configuredDimensionRoot( + worldContainer.toFile(), + levelRoot.toFile(), + worldKey + ).toPath().toAbsolutePath().normalize(); + if (!dimensionDirectory.equals(configuredDimension)) { + throw new IllegalStateException("Iris world storage does not match the current platform layout."); + } + Path configuredLevel = IrisWorldStorage.configuredLevelRoot( + worldContainer.toFile(), + levelRoot.toFile(), + worldKey + ).toPath().toAbsolutePath().normalize(); + return new StorageLayout(configuredDimension, configuredLevel); + } + private static String requireIdentifier(String identifier) { if (identifier == null || identifier.isBlank()) { throw new Rejection(RejectionReason.INVALID_IDENTIFIER, "The world identifier cannot be empty."); @@ -129,7 +191,8 @@ public final class WorldRemovalPathPolicy { NamespacedKey worldKey, String logicalName, Path levelRoot, - Path worldDirectory + Path worldDirectory, + Path storageDirectory ) { public Target { Objects.requireNonNull(requestedIdentifier, "requestedIdentifier"); @@ -137,9 +200,13 @@ public final class WorldRemovalPathPolicy { Objects.requireNonNull(logicalName, "logicalName"); Objects.requireNonNull(levelRoot, "levelRoot"); Objects.requireNonNull(worldDirectory, "worldDirectory"); + Objects.requireNonNull(storageDirectory, "storageDirectory"); } } + private record StorageLayout(Path dimensionDirectory, Path storageDirectory) { + } + public enum RejectionReason { INVALID_IDENTIFIER, CONFIGURED_MAIN_WORLD, diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java index 38cd39be1..f2d9b307c 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java @@ -165,10 +165,22 @@ public final class BukkitWorldConfiguration { ))) { continue; } - if (!IrisWorldStorage.isExistingManagedDimensionRoot( - requiredLevelRoot.toFile(), - namespacedKey - )) { + Path worldContainer = requiredLevelRoot.getParent(); + if (worldContainer == null) { + throw new IOException("Selected level root has no world container: " + requiredLevelRoot); + } + boolean storagePresent; + try { + storagePresent = IrisWorldStorage.frozenDimensionRoot( + worldContainer.toFile(), + requiredLevelRoot.toFile(), + configuredName, + namespacedKey + ).isPresent(); + } catch (IllegalStateException failure) { + storagePresent = false; + } + if (!storagePresent) { continue; } String dimension = selectedIrisDimension(configuredGenerator, configuredName); diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java b/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java index eb76aaf8d..ace825999 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java @@ -852,7 +852,9 @@ public final class IrisWorldRemovalService { ) { return onGlobal(() -> { requireNotTerminal(terminal, "Multiverse unregistration"); - return IrisServices.get(MultiverseCoreLink.class).removeFromConfig(target.logicalName()); + return IrisServices.get(MultiverseCoreLink.class).removeFromConfig( + bukkitConfigurationWorldName(target) + ); }).thenCompose(multiverseChanged -> { if (terminal.getAsBoolean()) { return CompletableFuture.failedFuture(new IllegalStateException( @@ -892,7 +894,10 @@ public final class IrisWorldRemovalService { WorldRemovalPathPolicy.Target target, BooleanSupplier terminal ) { - Path quarantine = target.worldDirectory().resolveSibling(".iris-delete-" + UUID.randomUUID()); + Path quarantine = target.levelRoot() + .resolve("dimensions/iris/.iris-delete-" + UUID.randomUUID()) + .toAbsolutePath() + .normalize(); return CompletableFuture.supplyAsync( () -> { requireNotTerminal(terminal, "deletion intent"); @@ -952,7 +957,12 @@ public final class IrisWorldRemovalService { target.worldKey(), target.worldDirectory() ); - boolean directoryPresent = Files.isDirectory(target.worldDirectory(), LinkOption.NOFOLLOW_LINKS); + WorldRemovalPathPolicy.validateStorageRoot( + target.levelRoot(), + target.worldKey(), + target.storageDirectory() + ); + boolean directoryPresent = Files.isDirectory(target.storageDirectory(), LinkOption.NOFOLLOW_LINKS); YamlConfiguration configuration = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML); String generator = bukkitGenerator(configuration, target); boolean configurationManaged = generator != null @@ -1020,17 +1030,24 @@ public final class IrisWorldRemovalService { target.worldKey(), target.worldDirectory() ); - Path worldDirectory = target.worldDirectory(); - if (!Files.exists(worldDirectory, LinkOption.NOFOLLOW_LINKS)) { + WorldRemovalPathPolicy.validateStorageRoot( + target.levelRoot(), + target.worldKey(), + target.storageDirectory() + ); + Path storageDirectory = target.storageDirectory(); + if (!Files.exists(storageDirectory, LinkOption.NOFOLLOW_LINKS)) { return null; } - if (!Files.isDirectory(worldDirectory, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isDirectory(storageDirectory, LinkOption.NOFOLLOW_LINKS)) { throw new RemovalFailure( RemovalStatus.QUARANTINE_FAILED, - new IOException("Iris world target is not a directory: " + worldDirectory) + new IOException("Iris world target is not a directory: " + storageDirectory) ); } + requireSafeQuarantineParent(target, quarantine); + WorldDeletionQueue deletionQueue = IrisServices.getOrNull(WorldDeletionQueue.class); if (deletionQueue == null) { throw new RemovalFailure( @@ -1059,21 +1076,26 @@ public final class IrisWorldRemovalService { target.worldKey(), target.worldDirectory() ); - Path worldDirectory = target.worldDirectory(); - if (!Files.exists(worldDirectory, LinkOption.NOFOLLOW_LINKS)) { + WorldRemovalPathPolicy.validateStorageRoot( + target.levelRoot(), + target.worldKey(), + target.storageDirectory() + ); + Path storageDirectory = target.storageDirectory(); + if (!Files.exists(storageDirectory, LinkOption.NOFOLLOW_LINKS)) { return null; } - if (!Files.isDirectory(worldDirectory, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isDirectory(storageDirectory, LinkOption.NOFOLLOW_LINKS)) { throw new RemovalFailure( RemovalStatus.QUARANTINE_FAILED, - new IOException("Iris world target is not a directory: " + worldDirectory) + new IOException("Iris world target is not a directory: " + storageDirectory) ); } try { try { - Files.move(worldDirectory, quarantine, StandardCopyOption.ATOMIC_MOVE); + Files.move(storageDirectory, quarantine, StandardCopyOption.ATOMIC_MOVE); } catch (AtomicMoveNotSupportedException unsupported) { - Files.move(worldDirectory, quarantine); + Files.move(storageDirectory, quarantine); } return quarantine; } catch (IOException failure) { @@ -1081,6 +1103,31 @@ public final class IrisWorldRemovalService { } } + private static void requireSafeQuarantineParent( + WorldRemovalPathPolicy.Target target, + Path quarantine + ) { + Path expectedParent = target.levelRoot().resolve("dimensions/iris").toAbsolutePath().normalize(); + if (!Objects.equals(quarantine.getParent(), expectedParent)) { + throw new RemovalFailure( + RemovalStatus.QUARANTINE_FAILED, + new IOException("Iris quarantine path is outside its exact namespace root: " + quarantine) + ); + } + try { + Path current = target.levelRoot().toAbsolutePath().normalize(); + for (Path segment : target.levelRoot().relativize(expectedParent)) { + current = current.resolve(segment); + if (Files.isSymbolicLink(current)) { + throw new IOException("Iris quarantine storage contains a symbolic link: " + current); + } + } + Files.createDirectories(expectedParent); + } catch (IOException failure) { + throw new RemovalFailure(RemovalStatus.QUARANTINE_FAILED, failure); + } + } + private DeleteDisposition deleteQuarantine(Path quarantine) { try { SnapshotDirectoryTreeDeleter.delete(quarantine); diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleRequest.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleRequest.java index aaf161602..d1594d03f 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleRequest.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleRequest.java @@ -40,7 +40,7 @@ public record WorldLifecycleRequest( } public WorldCreator toWorldCreator() { - WorldCreator creator = WorldCreatorCompat.ofKey(worldKey) + WorldCreator creator = WorldCreatorCompat.ofKey(worldKey, worldName) .environment(environment) .generateStructures(generateStructures) .hardcore(hardcore) diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java index 68c3f86da..858fdbd2b 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java @@ -44,6 +44,11 @@ public final class WorldLifecycleStaging { return stagedStemGenerators.remove(worldName); } + @Nullable + public static ChunkGenerator peekStemGenerator(@NotNull String worldName) { + return stagedStemGenerators.get(worldName); + } + public static void clearGenerator(@NotNull String worldName) { stagedGenerators.remove(worldName); stagedBiomeProviders.remove(worldName); diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java index c15dd9417..5b36bcf6e 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrap.java @@ -1,6 +1,7 @@ package art.arcane.iris.core.lifecycle; import art.arcane.iris.core.ExactWorldSlotPathPolicy; +import art.arcane.iris.core.WorldSlotKey; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot; import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths; import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase; @@ -8,6 +9,8 @@ import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction; 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.List; import java.util.Objects; @@ -138,6 +141,7 @@ public final class WorldReplacementBootstrap { throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state."); } try { + refreshOverworldEntryGuard(levelRoot, active, paths); WorldReplacementFilesystem.publish( paths, active.originalTargetPresent(), @@ -166,6 +170,7 @@ public final class WorldReplacementBootstrap { } throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state."); } + refreshOverworldEntryGuard(levelRoot, active, paths); WorldReplacementFilesystem.publish( paths, active.originalTargetPresent(), @@ -176,6 +181,20 @@ public final class WorldReplacementBootstrap { throw conflict(active, "Replacement journal reached an unsupported bootstrap phase."); } + private static void refreshOverworldEntryGuard( + Path levelRoot, + Transaction transaction, + ReplacementPaths paths + ) throws IOException { + if (!WorldSlotKey.minecraft("overworld").equals(transaction.worldKey())) { + return; + } + Path replacementWorld = Files.isDirectory(paths.stage(), LinkOption.NOFOLLOW_LINKS) + ? paths.stage() + : paths.target(); + WorldReplacementEntryGuard.refreshPlayers(levelRoot, replacementWorld, transaction.id()); + } + private static void rollback( Path dataDirectory, File bukkitConfiguration, diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementEntryGuard.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementEntryGuard.java new file mode 100644 index 000000000..95d4dc67e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldReplacementEntryGuard.java @@ -0,0 +1,231 @@ +package art.arcane.iris.core.lifecycle; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class WorldReplacementEntryGuard { + public static final String MARKER_NAME = "replacement-entry.properties"; + + private static final Pattern PLAYER_DATA_NAME = Pattern.compile( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\\.dat$"); + + private WorldReplacementEntryGuard() { + } + + public static Entry stage(Path levelRoot, Path stagedWorld, UUID transactionId) throws IOException { + Path requiredLevelRoot = normalize(levelRoot, "levelRoot"); + Path requiredStagedWorld = normalize(stagedWorld, "stagedWorld"); + Entry entry = new Entry( + Objects.requireNonNull(transactionId, "transactionId"), + discoverPlayers(requiredLevelRoot) + ); + write(requiredStagedWorld, entry); + return entry; + } + + public static Entry refreshPlayers(Path levelRoot, Path worldDirectory, UUID transactionId) throws IOException { + Path requiredLevelRoot = normalize(levelRoot, "levelRoot"); + Path requiredWorldDirectory = normalize(worldDirectory, "worldDirectory"); + UUID requiredTransactionId = Objects.requireNonNull(transactionId, "transactionId"); + Optional loaded = load(requiredWorldDirectory); + if (loaded.isEmpty()) { + throw new IOException("The staged Overworld replacement is missing its entry marker."); + } + Entry current = loaded.get(); + if (!current.transactionId().equals(requiredTransactionId)) { + throw new IOException("Replacement entry marker belongs to another transaction."); + } + HashSet pendingPlayers = new HashSet<>(current.pendingPlayers()); + pendingPlayers.addAll(discoverPlayers(requiredLevelRoot)); + Entry refreshed = new Entry(requiredTransactionId, pendingPlayers); + write(requiredWorldDirectory, refreshed); + return refreshed; + } + + public static Optional load(Path worldDirectory) throws IOException { + Path marker = marker(worldDirectory); + if (!Files.exists(marker, LinkOption.NOFOLLOW_LINKS)) { + return Optional.empty(); + } + if (Files.isSymbolicLink(marker) || !Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Replacement entry marker is unsafe: " + marker); + } + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(marker)) { + properties.load(input); + } + UUID transactionId; + try { + transactionId = UUID.fromString(required(properties, "transaction")); + } catch (IllegalArgumentException failure) { + throw new IOException("Replacement entry marker contains an invalid transaction id.", failure); + } + HashSet pendingPlayers = new HashSet<>(); + String encodedPlayers = properties.getProperty("pendingPlayers", "").trim(); + if (!encodedPlayers.isEmpty()) { + for (String encodedPlayer : encodedPlayers.split(",", -1)) { + try { + if (!pendingPlayers.add(UUID.fromString(encodedPlayer))) { + throw new IOException("Replacement entry marker contains a duplicate player id."); + } + } catch (IllegalArgumentException failure) { + throw new IOException("Replacement entry marker contains an invalid player id.", failure); + } + } + } + return Optional.of(new Entry(transactionId, pendingPlayers)); + } + + public static Optional completePlayer( + Path worldDirectory, + UUID transactionId, + UUID playerId + ) throws IOException { + Path requiredWorldDirectory = normalize(worldDirectory, "worldDirectory"); + UUID requiredTransactionId = Objects.requireNonNull(transactionId, "transactionId"); + UUID requiredPlayerId = Objects.requireNonNull(playerId, "playerId"); + Optional loaded = load(requiredWorldDirectory); + if (loaded.isEmpty()) { + return Optional.empty(); + } + Entry current = loaded.get(); + if (!current.transactionId().equals(requiredTransactionId)) { + throw new IOException("Replacement entry marker belongs to another transaction."); + } + HashSet remaining = new HashSet<>(current.pendingPlayers()); + remaining.remove(requiredPlayerId); + Entry updated = new Entry(requiredTransactionId, remaining); + write(requiredWorldDirectory, updated); + return Optional.of(updated); + } + + public static boolean retireIfEmpty(Path worldDirectory, UUID transactionId) throws IOException { + Path requiredWorldDirectory = normalize(worldDirectory, "worldDirectory"); + Optional loaded = load(requiredWorldDirectory); + if (loaded.isEmpty()) { + return true; + } + Entry current = loaded.get(); + if (!current.transactionId().equals(Objects.requireNonNull(transactionId, "transactionId"))) { + throw new IOException("Replacement entry marker belongs to another transaction."); + } + if (!current.pendingPlayers().isEmpty()) { + return false; + } + Path marker = marker(requiredWorldDirectory); + Files.delete(marker); + DirectoryDurability.forceDirectoryAfterCommit(marker.getParent(), "A replacement entry marker retirement"); + return true; + } + + private static Set discoverPlayers(Path levelRoot) throws IOException { + Path playerData = levelRoot.resolve("players/data"); + if (!Files.exists(playerData, LinkOption.NOFOLLOW_LINKS)) { + return Set.of(); + } + if (Files.isSymbolicLink(playerData) || !Files.isDirectory(playerData, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Player data storage is unsafe: " + playerData); + } + HashSet players = new HashSet<>(); + try (DirectoryStream files = Files.newDirectoryStream(playerData)) { + for (Path file : files) { + Matcher matcher = PLAYER_DATA_NAME.matcher(file.getFileName().toString()); + if (!matcher.matches()) { + continue; + } + if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Player data entry is unsafe: " + file); + } + players.add(UUID.fromString(matcher.group(1))); + } + } + return Set.copyOf(players); + } + + private static void write(Path worldDirectory, Entry entry) throws IOException { + Path marker = marker(worldDirectory); + Path parent = marker.getParent(); + if (Files.isSymbolicLink(parent)) { + throw new IOException("Replacement entry storage is unsafe: " + parent); + } + Files.createDirectories(parent); + if (!Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Replacement entry storage is not a directory: " + parent); + } + Properties properties = new Properties(); + properties.setProperty("transaction", entry.transactionId().toString()); + ArrayList orderedPlayers = new ArrayList<>(entry.pendingPlayers()); + orderedPlayers.sort(Comparator.comparing(UUID::toString)); + properties.setProperty( + "pendingPlayers", + String.join(",", orderedPlayers.stream().map(UUID::toString).toList()) + ); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + properties.store(output, null); + Path staged = Files.createTempFile(parent, ".replacement-entry-", ".tmp"); + try { + try (FileChannel channel = FileChannel.open( + staged, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE + )) { + ByteBuffer buffer = ByteBuffer.wrap(output.toByteArray()); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + try { + Files.move(staged, marker, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException failure) { + throw new IOException("Replacement entry marker requires atomic publication.", failure); + } + DirectoryDurability.forceDirectoryAfterCommit(parent, "A replacement entry marker change"); + } finally { + Files.deleteIfExists(staged); + } + } + + private static Path marker(Path worldDirectory) { + return normalize(worldDirectory, "worldDirectory").resolve("iris").resolve(MARKER_NAME); + } + + private static Path normalize(Path path, String name) { + return Objects.requireNonNull(path, name).toAbsolutePath().normalize(); + } + + private static String required(Properties properties, String key) throws IOException { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + throw new IOException("Replacement entry marker is missing " + key + "."); + } + return value.trim(); + } + + public record Entry(UUID transactionId, Set pendingPlayers) { + public Entry { + Objects.requireNonNull(transactionId, "transactionId"); + pendingPlayers = Set.copyOf(Objects.requireNonNull(pendingPlayers, "pendingPlayers")); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java index 5fcbbdea5..7a5b79203 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/IrisPregenerator.java @@ -418,9 +418,7 @@ public class IrisPregenerator { IrisLogging.reportError(e); } - if (MantleHeapPressure.overPanicWater()) { - MantleHeapPressure.requestPanicReclaim(); - } + MantleHeapPressure.requestPanicReclaim(); } private void checkRegion(int x, int z) { diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java b/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java index 19f30ed3c..032755be3 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/MantleHeapPressure.java @@ -18,18 +18,37 @@ package art.arcane.iris.core.pregenerator; +import art.arcane.iris.spi.IrisLogging; + +import javax.management.MBeanServer; +import javax.management.ObjectName; import java.lang.management.ManagementFactory; import java.lang.management.MemoryUsage; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BiConsumer; +import java.util.function.DoubleConsumer; +import java.util.function.LongSupplier; public final class MantleHeapPressure { private static final double HIGH_WATER = 0.92D; private static final double LOW_WATER = 0.82D; - private static final double PANIC_WATER = 0.96D; - private static final long PANIC_GC_INTERVAL_MS = 30_000L; - private static final AtomicBoolean engaged = new AtomicBoolean(false); - private static final AtomicLong lastPanicGcAt = new AtomicLong(0L); + private static final long MAXIMUM_HYSTERESIS_MS = 60_000L; + private static final ObjectName DIAGNOSTIC_COMMAND = diagnosticCommandName(); + private static final PanicGcReclaimer PANIC_RECLAIMER = new PanicGcReclaimer( + new PanicGcPolicy(10_000L, 60_000L, 60_000L, 15 * 60_000L), + new PanicGcActions( + System::currentTimeMillis, + System::gc, + () -> invokeHotSpotDiagnosticGc(ManagementFactory.getPlatformMBeanServer()), + (double fraction) -> IrisLogging.warn( + "Iris heap remained at %.1f%% after normal panic reclaim; invoking the current JVM's diagnostic full GC to keep generation live.", + fraction * 100.0D), + (String context, Throwable failure) -> IrisLogging.reportError(context, failure))); + private static final HeapPressureGate PRESSURE_GATE = new HeapPressureGate( + HIGH_WATER, + LOW_WATER, + MAXIMUM_HYSTERESIS_MS, + System::currentTimeMillis, + PANIC_RECLAIMER::resetEpisode); private MantleHeapPressure() { } @@ -50,19 +69,7 @@ public final class MantleHeapPressure { } public static boolean overHighWater() { - double fraction = usedFraction(); - if (engaged.get()) { - if (fraction <= LOW_WATER) { - engaged.set(false); - return false; - } - return true; - } - if (fraction >= HIGH_WATER) { - engaged.set(true); - return true; - } - return false; + return PRESSURE_GATE.update(usedFraction()); } public static double reclaimUrgency(double fraction) { @@ -75,18 +82,203 @@ public final class MantleHeapPressure { return (fraction - LOW_WATER) / (HIGH_WATER - LOW_WATER); } - public static boolean overPanicWater() { - return usedFraction() >= PANIC_WATER; + public static void requestPanicReclaim() { + PANIC_RECLAIMER.request(usedFraction()); } - public static void requestPanicReclaim() { - long now = System.currentTimeMillis(); - long last = lastPanicGcAt.get(); - if (now - last < PANIC_GC_INTERVAL_MS) { - return; + static void invokeHotSpotDiagnosticGc(MBeanServer server) throws Exception { + if (!server.isRegistered(DIAGNOSTIC_COMMAND)) { + throw new UnsupportedOperationException("HotSpot DiagnosticCommand MBean is not registered on this JVM"); } - if (lastPanicGcAt.compareAndSet(last, now)) { - System.gc(); + server.invoke( + DIAGNOSTIC_COMMAND, + "gcRun", + new Object[0], + new String[0]); + } + + private static ObjectName diagnosticCommandName() { + try { + return new ObjectName("com.sun.management:type=DiagnosticCommand"); + } catch (Exception failure) { + throw new ExceptionInInitializerError(failure); + } + } + + record PanicGcPolicy( + long diagnosticDelayMs, + long diagnosticCooldownMs, + long initialFailureBackoffMs, + long maximumFailureBackoffMs + ) { + PanicGcPolicy { + if (diagnosticDelayMs < 0L + || diagnosticCooldownMs < 0L + || initialFailureBackoffMs < 0L + || maximumFailureBackoffMs < initialFailureBackoffMs) { + throw new IllegalArgumentException("Invalid panic GC timing policy"); + } + } + } + + record PanicGcActions( + LongSupplier clock, + Runnable explicitGc, + DiagnosticGc diagnosticGc, + DoubleConsumer diagnosticStart, + BiConsumer failureSink + ) { + } + + @FunctionalInterface + interface DiagnosticGc { + void run() throws Exception; + } + + static final class HeapPressureGate { + private static final long NOT_BELOW_HIGH_WATER = Long.MIN_VALUE; + + private final double highWater; + private final double lowWater; + private final long maximumHysteresisMs; + private final LongSupplier clock; + private final Runnable releaseAction; + private boolean engaged; + private long belowHighWaterSince; + + HeapPressureGate(double highWater, double lowWater, long maximumHysteresisMs, LongSupplier clock, Runnable releaseAction) { + if (!Double.isFinite(highWater) + || !Double.isFinite(lowWater) + || lowWater < 0.0D + || highWater <= lowWater + || maximumHysteresisMs < 0L) { + throw new IllegalArgumentException("Invalid heap pressure hysteresis policy"); + } + this.highWater = highWater; + this.lowWater = lowWater; + this.maximumHysteresisMs = maximumHysteresisMs; + this.clock = clock; + this.releaseAction = releaseAction; + this.belowHighWaterSince = NOT_BELOW_HIGH_WATER; + } + + synchronized boolean update(double fraction) { + if (!Double.isFinite(fraction)) { + return engaged; + } + if (!engaged) { + if (fraction >= highWater) { + engaged = true; + belowHighWaterSince = NOT_BELOW_HIGH_WATER; + } + return engaged; + } + if (fraction <= lowWater) { + release(); + return false; + } + if (fraction >= highWater) { + belowHighWaterSince = NOT_BELOW_HIGH_WATER; + return true; + } + + long now = clock.getAsLong(); + if (belowHighWaterSince == NOT_BELOW_HIGH_WATER) { + belowHighWaterSince = now; + return true; + } + if (elapsed(now, belowHighWaterSince) < maximumHysteresisMs) { + return true; + } + + release(); + return false; + } + + private void release() { + engaged = false; + belowHighWaterSince = NOT_BELOW_HIGH_WATER; + releaseAction.run(); + } + + private static long elapsed(long now, long then) { + return now >= then ? now - then : Long.MAX_VALUE; + } + } + + static final class PanicGcReclaimer { + private final PanicGcPolicy policy; + private final PanicGcActions actions; + private boolean panicEpisode; + private long explicitAttemptAt; + private long nextDiagnosticAllowedAt; + private long failureBackoffMs; + + PanicGcReclaimer(PanicGcPolicy policy, PanicGcActions actions) { + this.policy = policy; + this.actions = actions; + this.failureBackoffMs = policy.initialFailureBackoffMs(); + } + + synchronized void request(double fraction) { + if (!Double.isFinite(fraction) || fraction <= LOW_WATER) { + return; + } + + long now = actions.clock().getAsLong(); + if (!panicEpisode) { + if (fraction < HIGH_WATER) { + return; + } + beginEpisode(now); + return; + } + if (elapsed(now, explicitAttemptAt) < policy.diagnosticDelayMs() + || now < nextDiagnosticAllowedAt) { + return; + } + + actions.diagnosticStart().accept(fraction); + try { + actions.diagnosticGc().run(); + failureBackoffMs = policy.initialFailureBackoffMs(); + nextDiagnosticAllowedAt = deadline(now, policy.diagnosticCooldownMs()); + } catch (Exception failure) { + actions.failureSink().accept( + "Iris could not invoke the current JVM's DiagnosticCommand GC after normal panic reclaim was ineffective; generation remains pressure-limited to avoid an OOM.", + failure); + nextDiagnosticAllowedAt = deadline(now, failureBackoffMs); + failureBackoffMs = Math.min(policy.maximumFailureBackoffMs(), doubled(failureBackoffMs)); + } + } + + synchronized void resetEpisode() { + panicEpisode = false; + explicitAttemptAt = 0L; + } + + private void beginEpisode(long now) { + panicEpisode = true; + explicitAttemptAt = now; + try { + actions.explicitGc().run(); + } catch (RuntimeException failure) { + actions.failureSink().accept( + "Iris normal panic heap reclaim failed; the diagnostic fallback will be attempted if pressure remains critical.", + failure); + } + } + + private static long elapsed(long now, long then) { + return now >= then ? now - then : Long.MAX_VALUE; + } + + private static long deadline(long now, long delay) { + return delay > Long.MAX_VALUE - now ? Long.MAX_VALUE : now + delay; + } + + private static long doubled(long value) { + return value > Long.MAX_VALUE / 2L ? Long.MAX_VALUE : value * 2L; } } } diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java index 87cd9d45f..834c6f5be 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenMantleBackpressure.java @@ -133,9 +133,7 @@ public final class PregenMantleBackpressure { IrisLogging.reportError(e); } - if (MantleHeapPressure.overPanicWater()) { - MantleHeapPressure.requestPanicReclaim(); - } + MantleHeapPressure.requestPanicReclaim(); long elapsed = M.ms() - waitStart; if (elapsed >= timeoutMs) { diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java index ef9bc05ef..9fb9926b7 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java @@ -35,6 +35,7 @@ import java.util.Map; @Builder @Data public class PregenTask { + static final int MAX_WORLD_BLOCK = 29_999_984; /** * Saturation limits for block bounds. The full int range is safe downstream: the widest derived value is * regionToChunk(blockToRegionFloor(MAX_BLOCK)) + 31 shifted back to blocks, which lands inside int. @@ -71,6 +72,7 @@ public class PregenTask { if (radiusX <= 0 || radiusZ <= 0) { throw new IllegalArgumentException("Pregen radii must be greater than zero blocks."); } + requireWithinWorld(center, radiusX, radiusZ); this.gui = gui; this.center = new ProxiedPos(center); @@ -79,6 +81,19 @@ public class PregenTask { bounds.update(); } + private static void requireWithinWorld(Position2 center, int radiusX, int radiusZ) { + long minX = (long) center.getX() - radiusX; + long maxX = (long) center.getX() + radiusX; + long minZ = (long) center.getZ() - radiusZ; + long maxZ = (long) center.getZ() + radiusZ; + if (minX < -MAX_WORLD_BLOCK || maxX > MAX_WORLD_BLOCK + || minZ < -MAX_WORLD_BLOCK || maxZ > MAX_WORLD_BLOCK) { + throw new IllegalArgumentException("Pregen area exceeds Minecraft's coordinate limit of +/-" + + MAX_WORLD_BLOCK + " blocks: center " + center.getX() + "," + center.getZ() + + " radius " + radiusX + "x" + radiusZ + "."); + } + } + public static void iterateRegion(int xr, int zr, Spiraled s, Position2 pull) { iterateRegion(xr, zr, s, pull.getX(), pull.getZ()); } diff --git a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java index ff5b87dbe..deae3c310 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/WorldRuntimeControlService.java @@ -17,10 +17,16 @@ import org.bukkit.Chunk; import org.bukkit.GameRule; import org.bukkit.HeightMap; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.Waterlogged; import org.bukkit.entity.Player; import org.bukkit.event.world.TimeSkipEvent; import org.bukkit.plugin.PluginManager; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.VoxelShape; import java.lang.reflect.Field; import java.lang.reflect.Method; @@ -31,6 +37,26 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; public final class WorldRuntimeControlService { + private static final int MAX_SAFE_ENTRY_HORIZONTAL_RADIUS = 15; + private static final int MAX_SAFE_ENTRY_VERTICAL_SEARCH = 64; + private static final double BLOCK_CENTER = 0.5D; + private static final double COLLISION_EPSILON = 0.000001D; + private static final Set UNSAFE_ENTRY_MATERIALS = Set.of( + Material.CACTUS, + Material.CAMPFIRE, + Material.COBWEB, + Material.END_GATEWAY, + Material.END_PORTAL, + Material.FIRE, + Material.MAGMA_BLOCK, + Material.NETHER_PORTAL, + Material.POINTED_DRIPSTONE, + Material.POWDER_SNOW, + Material.SOUL_CAMPFIRE, + Material.SOUL_FIRE, + Material.SWEET_BERRY_BUSH, + Material.WITHER_ROSE + ); private static volatile WorldRuntimeControlService instance; private final CapabilitySnapshot capabilities; @@ -352,19 +378,138 @@ public final class WorldRuntimeControlService { } static Location findTopSafeLocation(World world, Location source) { - int x = source.getBlockX(); - int z = source.getBlockZ(); + int sourceX = source.getBlockX(); + int sourceZ = source.getBlockZ(); float yaw = source.getYaw(); float pitch = source.getPitch(); - int minY = world.getMinHeight() + 1; - int maxY = world.getMaxHeight() - 2; - if (world.isChunkLoaded(x >> 4, z >> 4)) { - int raw = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING_NO_LEAVES); - int y = Math.max(minY, Math.min(maxY, raw + 1)); - return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch); + int chunkX = sourceX >> 4; + int chunkZ = sourceZ >> 4; + if (!world.isChunkLoaded(chunkX, chunkZ)) { + return null; } - int y = Math.max(minY, Math.min(maxY, source.getBlockY())); - return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch); + + int minimumFloorY = world.getMinHeight(); + int maximumFloorY = world.getMaxHeight() - 3; + if (minimumFloorY > maximumFloorY) { + return null; + } + + int minimumX = chunkX << 4; + int minimumZ = chunkZ << 4; + int maximumX = minimumX + 15; + int maximumZ = minimumZ + 15; + for (int radius = 0; radius <= MAX_SAFE_ENTRY_HORIZONTAL_RADIUS; radius++) { + for (int offsetX = -radius; offsetX <= radius; offsetX++) { + for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) { + if (Math.max(Math.abs(offsetX), Math.abs(offsetZ)) != radius) { + continue; + } + + int x = sourceX + offsetX; + int z = sourceZ + offsetZ; + if (x < minimumX || x > maximumX || z < minimumZ || z > maximumZ) { + continue; + } + + Location safeLocation = findSafeLocationInColumn( + world, + x, + z, + minimumFloorY, + maximumFloorY, + yaw, + pitch + ); + if (safeLocation != null) { + return safeLocation; + } + } + } + } + + return null; + } + + private static Location findSafeLocationInColumn( + World world, + int x, + int z, + int minimumFloorY, + int maximumFloorY, + float yaw, + float pitch + ) { + int highestY = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING_NO_LEAVES); + int startingFloorY = Math.max(minimumFloorY, Math.min(maximumFloorY, highestY)); + int lowestFloorY = Math.max(minimumFloorY, startingFloorY - MAX_SAFE_ENTRY_VERTICAL_SEARCH + 1); + for (int floorY = startingFloorY; floorY >= lowestFloorY; floorY--) { + Block floor = world.getBlockAt(x, floorY, z); + if (!isSafeFloor(floor)) { + continue; + } + + Block feet = world.getBlockAt(x, floorY + 1, z); + Block head = world.getBlockAt(x, floorY + 2, z); + if (isClearEntryBlock(feet) && isClearEntryBlock(head)) { + return new Location(world, x + BLOCK_CENTER, floorY + 1D, z + BLOCK_CENTER, yaw, pitch); + } + } + + return null; + } + + private static boolean isSafeFloor(Block block) { + Material material = block.getType(); + if (material == null + || isAir(material) + || material.name().endsWith("_LEAVES") + || UNSAFE_ENTRY_MATERIALS.contains(material) + || block.isLiquid() + || block.isPassable() + || isWaterlogged(block)) { + return false; + } + + VoxelShape collisionShape = block.getCollisionShape(); + if (collisionShape == null) { + return false; + } + + for (BoundingBox boundingBox : collisionShape.getBoundingBoxes()) { + if (boundingBox.getMinX() <= BLOCK_CENTER + && boundingBox.getMaxX() >= BLOCK_CENTER + && boundingBox.getMinZ() <= BLOCK_CENTER + && boundingBox.getMaxZ() >= BLOCK_CENTER + && boundingBox.getMaxY() > COLLISION_EPSILON + && boundingBox.getMaxY() <= 1D + COLLISION_EPSILON) { + return true; + } + } + + return false; + } + + private static boolean isClearEntryBlock(Block block) { + Material material = block.getType(); + if (material == null + || block.isLiquid() + || isWaterlogged(block) + || UNSAFE_ENTRY_MATERIALS.contains(material) + || !block.isPassable()) { + return false; + } + + VoxelShape collisionShape = block.getCollisionShape(); + return collisionShape != null && collisionShape.getBoundingBoxes().isEmpty(); + } + + private static boolean isAir(Material material) { + return material == Material.AIR || material == Material.CAVE_AIR || material == Material.VOID_AIR; + } + + private static boolean isWaterlogged(Block block) { + BlockData blockData = block.getBlockData(); + return blockData instanceof Waterlogged waterlogged && waterlogged.isWaterlogged(); } @SuppressWarnings("unchecked") diff --git a/core/src/main/java/art/arcane/iris/core/service/EngineMaintenance.java b/core/src/main/java/art/arcane/iris/core/service/EngineMaintenance.java index 88530b8eb..6776dbda8 100644 --- a/core/src/main/java/art/arcane/iris/core/service/EngineMaintenance.java +++ b/core/src/main/java/art/arcane/iris/core/service/EngineMaintenance.java @@ -67,7 +67,7 @@ public final class EngineMaintenance { long unloadStart = System.nanoTime(); int unloadedTectonicPlates = engine.getMantle().unloadTectonicPlate( plan.multicoreUnload() ? 0 : Integer.MAX_VALUE); - if (plan.heapPressure() && MantleHeapPressure.overPanicWater()) { + if (plan.heapPressure()) { MantleHeapPressure.requestPanicReclaim(); } diff --git a/core/src/main/java/art/arcane/iris/core/service/GlobalCacheSVC.java b/core/src/main/java/art/arcane/iris/core/service/GlobalCacheSVC.java index 9bfd0e140..9b2466f18 100644 --- a/core/src/main/java/art/arcane/iris/core/service/GlobalCacheSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/GlobalCacheSVC.java @@ -11,6 +11,7 @@ import art.arcane.iris.util.common.plugin.IrisService; import art.arcane.volmlib.util.scheduling.Looper; import lombok.NonNull; import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -145,7 +146,21 @@ public class GlobalCacheSVC implements IrisService { private static PregenCache createDefault0(String worldIdentity) { if (disabled) return PregenCache.EMPTY; - File dimensionRoot = IrisWorldStorage.dimensionRoot(WorldIdentity.parse(worldIdentity)); + NamespacedKey worldKey = WorldIdentity.parse(worldIdentity); + File dimensionRoot = requireCacheDimensionRoot( + Bukkit.getWorldContainer(), + IrisWorldStorage.levelRoot(), + worldKey + ); return PregenCache.create(new File(dimensionRoot, "iris/pregen")).sync(); } + + static File requireCacheDimensionRoot(File worldContainer, File levelRoot, NamespacedKey worldKey) { + return IrisWorldStorage.requireFrozenDimensionRoot( + worldContainer, + levelRoot, + IrisWorldStorage.configuredWorldName(worldKey, levelRoot.getName()), + worldKey + ); + } } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java index e925309f9..5bb0c3caf 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java @@ -32,6 +32,7 @@ import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.IrisWorlds; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.WorldCreatorCompat; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration; import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator; import art.arcane.iris.core.lifecycle.WorldLifecycleCaller; @@ -165,7 +166,7 @@ public class IrisCreator { NamespacedKey worldKey; try { worldKey = IrisWorldStorage.managedKeyFromName(name); - } catch (IllegalArgumentException e) { + } catch (RuntimeException e) { throw new IrisException(e.getMessage(), e); } name = IrisWorldStorage.logicalName(worldKey); @@ -197,12 +198,19 @@ public class IrisCreator { private World createReserved(NamespacedKey worldKey, IrisDimension resolvedDimension) throws IrisException { File dimensionRoot; + File storageRoot; try { - dimensionRoot = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey); - } catch (IllegalArgumentException e) { + if (!studio && !benchmark) { + dimensionRoot = WorldCreatorCompat.persistentDimensionRoot(worldKey); + storageRoot = WorldCreatorCompat.persistentLevelRoot(worldKey); + } else { + dimensionRoot = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey); + storageRoot = dimensionRoot; + } + } catch (RuntimeException e) { throw new IrisException(e.getMessage(), e); } - if (Files.exists(dimensionRoot.toPath()) || WorldIdentity.resolve(worldKey).isPresent()) { + if (Files.exists(storageRoot.toPath()) || WorldIdentity.resolve(worldKey).isPresent()) { throw new IrisException("World \"" + name + "\" already exists or is loaded."); } if (sender == null) { @@ -249,6 +257,7 @@ public class IrisCreator { .name(name) .seed(seed) .studio(studio) + .persistent(!studio && !benchmark) .create(); reportStudioTiming("prepare_studio_generator", generatorPrepareStart); reportStudioProgress(0.40D, "install_datapacks"); @@ -343,7 +352,7 @@ public class IrisCreator { } return world; } catch (Throwable failure) { - rollbackWorldCreation(worldKey, world, stagedGenerator, dimensionRoot, bukkitRegistered, failure); + rollbackWorldCreation(worldKey, world, stagedGenerator, storageRoot, bukkitRegistered, failure); if (failure instanceof IrisException irisException) { throw irisException; } @@ -692,7 +701,7 @@ public class IrisCreator { NamespacedKey worldKey, World createdWorld, PlatformChunkGenerator stagedGenerator, - File dimensionRoot, + File storageRoot, boolean bukkitRegistered, Throwable failure ) { @@ -749,7 +758,12 @@ public class IrisCreator { if (bukkitRegistered) { try { CompletableFuture multiverseRemoval = J.sfut( - () -> IrisServices.get(MultiverseCoreLink.class).removeFromConfig(name) + () -> IrisServices.get(MultiverseCoreLink.class).removeFromConfig( + IrisWorldStorage.configuredWorldName( + worldKey, + IrisWorldStorage.levelRoot().getName() + ) + ) ); if (multiverseRemoval == null) { throw new IllegalStateException("Failed to schedule Multiverse rollback for \"" + name + "\"."); @@ -782,7 +796,7 @@ public class IrisCreator { return; } try { - AtomicDirectoryPublisher.deleteTree(dimensionRoot.toPath()); + AtomicDirectoryPublisher.deleteTree(storageRoot.toPath()); } catch (Throwable rollbackFailure) { failure.addSuppressed(rollbackFailure); queueRollbackDeletion(name, failure); diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java index 669d0815a..06f735a8c 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java @@ -38,6 +38,7 @@ public class IrisWorldCreator { private String dimensionName = null; private IrisDimension dimension; private long seed = 1337; + private boolean persistent; public IrisWorldCreator() { @@ -75,26 +76,35 @@ public class IrisWorldCreator { return this; } + public IrisWorldCreator persistent(boolean persistent) { + this.persistent = persistent; + return this; + } + public WorldCreator create() { IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension; NamespacedKey worldKey = IrisWorldStorage.keyFromName(name); World.Environment environment = findEnvironment(); + WorldCreator creator = persistent + ? WorldCreatorCompat.ofPersistentKey(worldKey) + : WorldCreatorCompat.ofKey(worldKey); + File worldFolder = persistent + ? WorldCreatorCompat.persistentDimensionRoot(worldKey) + : IrisWorldStorage.dimensionRoot(worldKey); IrisWorld w = IrisWorld.builder() .platformIdentity(worldKey.toString()) - .name(name) + .name(creator.name()) .minHeight(dim.getMinHeight()) .maxHeight(dim.getMaxHeight()) .seed(seed) - .worldFolder(IrisWorldStorage.dimensionRoot(worldKey)) + .worldFolder(worldFolder) .build(); ChunkGenerator g = new BukkitChunkGenerator(w, studio, studio ? dim.getLoader().getDataFolder() : new File(w.worldFolder(), "iris/pack"), dimensionName); - - return WorldCreatorCompat.ofKey(worldKey) - .environment(environment) + return creator.environment(environment) .generateStructures(true) .generator(g).seed(seed); } diff --git a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java index eef84ec95..872382230 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java @@ -118,23 +118,33 @@ final class EngineRuntimeBuilder { engine.runtime = null; } - try { - next.worldManager().start(); - } catch (Throwable e) { - Throwable cleanupFailure = engine.shutdownSequence.closeRuntime(next, e); - if (cleanupFailure != e) { - e.addSuppressed(cleanupFailure); - } - engine.lifecycleState = LifecycleState.FAILED; - throw new IllegalStateException("Failed to start the Iris world manager.", e); - } - engine.runtime = next; engine.publishedTarget = next.target(); engine.getGenerationSessions().activateNextSession(); engine.lifecycleState = LifecycleState.RUNNING; engine.getClosing().set(false); engine.backgroundTasks.openBackgroundTaskAdmission(); + try { + next.worldManager().start(); + } catch (Throwable e) { + engine.getClosing().set(true); + engine.backgroundTasks.closeBackgroundTaskAdmission(); + engine.lifecycleState = LifecycleState.FAILED; + try { + engine.getGenerationSessions().sealAndAwait( + "failed world manager start", + IrisEngine.SESSION_DRAIN_TIMEOUT_MILLIS, + true + ); + } catch (Throwable drainFailure) { + e.addSuppressed(drainFailure); + } + engine.shutdownSequence.closeRuntime(next, e); + if (engine.runtime == next) { + engine.runtime = null; + } + throw new IllegalStateException("Failed to start the Iris world manager.", e); + } scheduleRuntimeTasks(next); IrisLogging.debug("Engine Setup Complete " + next.cacheId()); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java index 4a94bfeac..6a4c2aa79 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolver.java @@ -1,10 +1,12 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.util.project.interpolation.IrisInterpolation; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.project.noise.CNG; +import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashMap; import java.util.IdentityHashMap; @@ -14,18 +16,20 @@ import java.util.Map; public final class IrisDimensionCarvingResolver { private static final int MAX_CHILD_DEPTH = 32; private static final long CHILD_SEED_SALT = 0x9E3779B97F4A7C15L; - private static final ThreadLocal THREAD_STATE = ThreadLocal.withInitial(State::new); + private static final ThreadLocal> THREAD_STATE = + ThreadLocal.withInitial(() -> new WeakReference<>(null)); private IrisDimensionCarvingResolver() { } public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY) { - return resolveRootEntry(engine, worldY, THREAD_STATE.get()); + return resolveRootEntry(engine, worldY, threadState()); } public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY, State state) { - State resolvedState = state == null ? THREAD_STATE.get() : state; + State resolvedState = state == null ? threadState() : state; + resolvedState.bind(engine); if (resolvedState.rootEntriesByWorldY.containsKey(worldY)) { return resolvedState.rootEntriesByWorldY.get(worldY); } @@ -51,11 +55,12 @@ public final class IrisDimensionCarvingResolver { } public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ) { - return resolveFromRoot(engine, rootEntry, worldX, worldZ, THREAD_STATE.get()); + return resolveFromRoot(engine, rootEntry, worldX, worldZ, threadState()); } public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, State state) { - State resolvedState = state == null ? THREAD_STATE.get() : state; + State resolvedState = state == null ? threadState() : state; + resolvedState.bind(engine); if (rootEntry == null) { return null; } @@ -103,6 +108,7 @@ public final class IrisDimensionCarvingResolver { return entry.getRealBiome(engine.getData()); } + state.bind(engine); if (state.biomeCache.containsKey(entry)) { return state.biomeCache.get(entry); } @@ -266,12 +272,48 @@ public final class IrisDimensionCarvingResolver { return state.childSeed; } + private static State threadState() { + WeakReference reference = THREAD_STATE.get(); + State state = reference.get(); + if (state != null) { + return state; + } + State replacement = new State(); + THREAD_STATE.set(new WeakReference<>(replacement)); + return replacement; + } + public static final class State { private final Map rootEntriesByWorldY = new HashMap<>(); private final Map selectionPlans = new IdentityHashMap<>(); private final Map biomeCache = new IdentityHashMap<>(); + private WeakReference engineIdentity; + private WeakReference dimensionIdentity; + private WeakReference dataIdentity; private Map entryIndex; private Long childSeed; + + private void bind(Engine engine) { + IrisDimension dimension = engine.getDimension(); + IrisData data = engine.getData(); + if (references(engineIdentity, engine) + && references(dimensionIdentity, dimension) + && references(dataIdentity, data)) { + return; + } + engineIdentity = new WeakReference<>(engine); + dimensionIdentity = new WeakReference<>(dimension); + dataIdentity = new WeakReference<>(data); + rootEntriesByWorldY.clear(); + selectionPlans.clear(); + biomeCache.clear(); + entryIndex = null; + childSeed = null; + } + + private static boolean references(WeakReference identity, Object value) { + return identity != null && identity.get() == value; + } } private static final class ParentSelectionPlan { diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index 04275ff51..7e4f8e841 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -250,7 +250,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun int minY = world.getMinHeight() + 1; int maxY = world.getMaxHeight() - 2; - int y = Math.max(minY, Math.min(maxY, world.getHighestBlockYAt(initialSpawn))); + int y = Math.max(minY, Math.min(maxY, world.getHighestBlockYAt(initialSpawn) + 1)); world.setSpawnLocation(new Location(world, initialSpawn.getX(), y, initialSpawn.getZ(), initialSpawn.getYaw(), initialSpawn.getPitch())); } diff --git a/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java b/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java index 74a0a5fb2..934431c45 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisWorldStorageTest.java @@ -255,4 +255,164 @@ public class IrisWorldStorageTest { assertFalse(IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, file)); assertTrue(IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, directory)); } + + @Test + public void persistentDimensionRootAllowsOnlyManagedAndExactVanillaSlots() throws Exception { + File levelRoot = temporaryFolder.newFolder("persistent-root"); + + assertEquals( + IrisWorldStorage.dimensionRoot(levelRoot, new NamespacedKey("iris", "moon")).getCanonicalFile(), + IrisWorldStorage.requireSafePersistentDimensionRoot( + levelRoot, + new NamespacedKey("iris", "moon") + ) + ); + assertEquals( + IrisWorldStorage.dimensionRoot(levelRoot, NamespacedKey.minecraft("overworld")).getCanonicalFile(), + IrisWorldStorage.requireSafePersistentDimensionRoot( + levelRoot, + NamespacedKey.minecraft("overworld") + ) + ); + assertEquals( + IrisWorldStorage.dimensionRoot(levelRoot, NamespacedKey.minecraft("the_nether")).getCanonicalFile(), + IrisWorldStorage.requireSafePersistentDimensionRoot( + levelRoot, + NamespacedKey.minecraft("the_nether") + ) + ); + assertEquals( + IrisWorldStorage.dimensionRoot(levelRoot, NamespacedKey.minecraft("the_end")).getCanonicalFile(), + IrisWorldStorage.requireSafePersistentDimensionRoot( + levelRoot, + NamespacedKey.minecraft("the_end") + ) + ); + assertThrows( + ExactWorldSlotPathPolicy.Rejection.class, + () -> IrisWorldStorage.requireSafePersistentDimensionRoot( + levelRoot, + NamespacedKey.minecraft("custom") + ) + ); + assertThrows( + ExactWorldSlotPathPolicy.Rejection.class, + () -> IrisWorldStorage.requireSafePersistentDimensionRoot( + levelRoot, + new NamespacedKey("foreign", "moon") + ) + ); + } + + @Test + public void frozenDimensionRootUsesCanonicalLevelStorageWhenPresent() throws Exception { + File worldContainer = temporaryFolder.newFolder("server-direct"); + File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile(); + NamespacedKey worldKey = new NamespacedKey("iris", "moon"); + File dimensionRoot = IrisWorldStorage.dimensionRoot(levelRoot, worldKey); + Files.createDirectories(dimensionRoot.toPath()); + + assertEquals( + dimensionRoot, + IrisWorldStorage.requireFrozenDimensionRoot( + worldContainer, + levelRoot, + "world_iris_moon", + worldKey + ) + ); + } + + @Test + public void frozenDimensionRootUsesCurrentCraftBukkitConfiguredStorage() throws Exception { + File worldContainer = temporaryFolder.newFolder("server-configured"); + File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile(); + NamespacedKey worldKey = new NamespacedKey("iris", "moon"); + File configuredLevelRoot = new File(worldContainer, "world_iris_moon"); + File configuredDimensionRoot = IrisWorldStorage.dimensionRoot(configuredLevelRoot, worldKey); + Files.createDirectories(configuredDimensionRoot.toPath()); + + assertEquals( + configuredDimensionRoot, + IrisWorldStorage.requireFrozenDimensionRoot( + worldContainer, + levelRoot, + "world_iris_moon", + worldKey + ) + ); + } + + @Test + public void frozenDimensionRootRejectsMissingAndAmbiguousStorage() throws Exception { + File worldContainer = temporaryFolder.newFolder("server-ambiguous"); + File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile(); + NamespacedKey worldKey = new NamespacedKey("iris", "moon"); + + assertThrows( + IllegalStateException.class, + () -> IrisWorldStorage.requireFrozenDimensionRoot( + worldContainer, + levelRoot, + "world_iris_moon", + worldKey + ) + ); + + Files.createDirectories(IrisWorldStorage.dimensionRoot(levelRoot, worldKey).toPath()); + File configuredLevelRoot = new File(worldContainer, "world_iris_moon"); + Files.createDirectories(IrisWorldStorage.dimensionRoot(configuredLevelRoot, worldKey).toPath()); + + assertThrows( + IllegalStateException.class, + () -> IrisWorldStorage.requireFrozenDimensionRoot( + worldContainer, + levelRoot, + "world_iris_moon", + worldKey + ) + ); + } + + @Test + public void frozenDimensionRootRejectsSymlinkedStorage() throws Exception { + File worldContainer = temporaryFolder.newFolder("server-symlink"); + File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile(); + Path namespaceRoot = Files.createDirectories(levelRoot.toPath().resolve("dimensions")); + Path outside = temporaryFolder.newFolder("frozen-outside").toPath(); + Files.createSymbolicLink(namespaceRoot.resolve("iris"), outside); + + assertThrows( + IllegalStateException.class, + () -> IrisWorldStorage.requireFrozenDimensionRoot( + worldContainer, + levelRoot, + "world_iris_moon", + new NamespacedKey("iris", "moon") + ) + ); + } + + @Test + public void frozenPackRootRequiresRealWorldLocalSnapshot() throws Exception { + File dimensionRoot = temporaryFolder.newFolder("frozen-pack-world"); + + assertThrows( + IllegalStateException.class, + () -> IrisWorldStorage.requireFrozenPackRoot(dimensionRoot) + ); + + Path irisRoot = Files.createDirectory(dimensionRoot.toPath().resolve("iris")); + Path externalPack = temporaryFolder.newFolder("external-pack").toPath(); + Files.createSymbolicLink(irisRoot.resolve("pack"), externalPack); + + assertThrows( + IllegalStateException.class, + () -> IrisWorldStorage.requireFrozenPackRoot(dimensionRoot) + ); + + Files.delete(irisRoot.resolve("pack")); + Path packRoot = Files.createDirectory(irisRoot.resolve("pack")); + assertEquals(packRoot.toFile(), IrisWorldStorage.requireFrozenPackRoot(dimensionRoot)); + } } diff --git a/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java b/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java index 64673f36a..f1442bec6 100644 --- a/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java +++ b/core/src/test/java/art/arcane/iris/core/IrisWorldsTest.java @@ -52,4 +52,20 @@ public class IrisWorldsTest { assertEquals(Set.of("world", "world_iris_moon"), selected.keySet()); assertEquals(Set.of("archive_iris_foreign"), other.keySet()); } + + @Test + public void bukkitWorldFilteringRecognizesCurrentCraftBukkitConfiguredStorage() throws Exception { + Path worldContainer = temporaryFolder.newFolder("configured-server").toPath(); + Path levelRoot = Files.createDirectory(worldContainer.resolve("world")); + Files.createDirectories( + worldContainer.resolve("world_iris_moon/dimensions/iris/moon") + ); + + Map selected = IrisWorlds.filterBukkitWorldsByStorage( + levelRoot, + Map.of("world_iris_moon", "overworld") + ); + + assertEquals(Map.of("world_iris_moon", "overworld"), selected); + } } diff --git a/core/src/test/java/art/arcane/iris/core/WorldCreatorCompatTest.java b/core/src/test/java/art/arcane/iris/core/WorldCreatorCompatTest.java index a587c7594..5260fc7db 100644 --- a/core/src/test/java/art/arcane/iris/core/WorldCreatorCompatTest.java +++ b/core/src/test/java/art/arcane/iris/core/WorldCreatorCompatTest.java @@ -21,11 +21,23 @@ public class WorldCreatorCompatTest { assertEquals("world_the_end", WorldCreatorCompat.fallbackName(NamespacedKey.minecraft("the_end"), "world")); } + @Test + public void persistentFallbackUsesExactConfiguredStartupName() { + assertEquals( + "world_iris_compat_world", + WorldCreatorCompat.fallbackPersistentName(new NamespacedKey("iris", "compat_world"), "world") + ); + } + @Test public void fallbackKeyRoundTripsCreatorName() { assertEquals(new NamespacedKey("iris", "compat_world"), WorldCreatorCompat.fallbackKey("compat_world", "world")); assertEquals(NamespacedKey.minecraft("overworld"), WorldCreatorCompat.fallbackKey("world", "world")); assertEquals(NamespacedKey.minecraft("the_nether"), WorldCreatorCompat.fallbackKey("world_nether", "world")); + assertEquals( + new NamespacedKey("iris", "compat_world"), + WorldCreatorCompat.fallbackKey("world_iris_compat_world", "world") + ); } @Test diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java index 975779712..0807b226b 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java @@ -112,6 +112,23 @@ public class BukkitWorldConfigurationTest { )), bindings); } + @Test + public void admitsCurrentCraftBukkitConfiguredWorldStorage() throws Exception { + Path worldContainer = temporaryFolder.newFolder("configured-binding-server").toPath(); + File configuration = worldContainer.resolve("bukkit.yml").toFile(); + Path levelRoot = Files.createDirectory(worldContainer.resolve("world")); + Files.createDirectories(worldContainer.resolve("world_iris_moon/dimensions/iris/moon")); + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("worlds.world_iris_moon.generator", "Iris:overworld"); + yaml.save(configuration); + + assertEquals(List.of(new BukkitWorldConfiguration.IrisGeneratorBinding( + "world_iris_moon", + new WorldSlotKey("iris", "moon"), + "overworld" + )), BukkitWorldConfiguration.readIrisGeneratorBindings(configuration, "world", levelRoot)); + } + @Test public void excludesSymlinkedCustomWorldTarget() throws Exception { File configuration = temporaryFolder.newFile("symlink-admission-bukkit.yml"); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleStagingTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleStagingTest.java index fd1c700de..523222afc 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleStagingTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleStagingTest.java @@ -30,6 +30,18 @@ public class WorldLifecycleStagingTest { assertSame(generator, WorldLifecycleStaging.consumeStemGenerator("world")); } + @Test + public void stagedStemGeneratorCanBeInspectedWithoutConsumption() { + ChunkGenerator generator = mock(ChunkGenerator.class); + + WorldLifecycleStaging.stageStemGenerator("world", generator); + + assertSame(generator, WorldLifecycleStaging.peekStemGenerator("world")); + assertSame(generator, WorldLifecycleStaging.peekStemGenerator("world")); + assertSame(generator, WorldLifecycleStaging.consumeStemGenerator("world")); + assertNull(WorldLifecycleStaging.peekStemGenerator("world")); + } + @Test public void stagedStemGeneratorCannotBeConsumedByDifferentWorldName() { ChunkGenerator generator = mock(ChunkGenerator.class); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java index f6200043c..eb8eb45eb 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldRemovalPathPolicyTest.java @@ -28,6 +28,28 @@ public class WorldRemovalPathPolicyTest { levelRoot.resolve("dimensions/iris/iris_world").toAbsolutePath().normalize(), target.worldDirectory() ); + assertEquals(target.worldDirectory(), target.storageDirectory()); + } + + @Test + public void resolvesCurrentCraftBukkitConfiguredDimensionDirectory() throws Exception { + Path worldContainer = temporaryFolder.newFolder("configured-removal-server").toPath(); + Path levelRoot = Files.createDirectory(worldContainer.resolve("world")); + Path dimensionRoot = Files.createDirectories( + worldContainer.resolve("world_iris_moon/dimensions/iris/moon") + ); + + WorldRemovalPathPolicy.Target target = WorldRemovalPathPolicy.resolve("moon", "world", levelRoot); + + assertEquals(dimensionRoot.toAbsolutePath().normalize(), target.worldDirectory()); + assertEquals(worldContainer.resolve("world_iris_moon").toAbsolutePath().normalize(), + target.storageDirectory()); + WorldRemovalPathPolicy.validateStoragePath(levelRoot, target.worldKey(), dimensionRoot); + WorldRemovalPathPolicy.validateStorageRoot( + levelRoot, + target.worldKey(), + worldContainer.resolve("world_iris_moon") + ); } @Test diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java index 86357e182..38bd5b4f4 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementBootstrapTest.java @@ -320,6 +320,7 @@ public class WorldReplacementBootstrapTest { Path overworldDimension = overworldPaths.stage().resolve("iris/pack/dimensions/overworld.json"); Files.createDirectories(overworldDimension.getParent()); Files.writeString(overworldDimension, "overworld-replacement"); + WorldReplacementEntryGuard.stage(levelRoot, overworldPaths.stage(), overworldId); String overworldFingerprint = WorldReplacementFilesystem.fingerprintPack( overworldPaths.stage().resolve("iris/pack") ); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementEntryGuardTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementEntryGuardTest.java new file mode 100644 index 000000000..94d7442ab --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldReplacementEntryGuardTest.java @@ -0,0 +1,109 @@ +package art.arcane.iris.core.lifecycle; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +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 WorldReplacementEntryGuardTest { + private static final UUID TRANSACTION_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID OTHER_TRANSACTION_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID FIRST_PLAYER = UUID.fromString("10000000-0000-0000-0000-000000000001"); + private static final UUID SECOND_PLAYER = UUID.fromString("20000000-0000-0000-0000-000000000002"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void stagesAndRefreshesCurrentPlayerReceipts() throws Exception { + Path levelRoot = temporaryFolder.newFolder("level-refresh").toPath(); + Path stagedWorld = temporaryFolder.newFolder("stage-refresh").toPath(); + writePlayer(levelRoot, FIRST_PLAYER, ".dat"); + writePlayer(levelRoot, SECOND_PLAYER, ".dat_old"); + Files.writeString(levelRoot.resolve("players/data/.DS_Store"), "ignored"); + + WorldReplacementEntryGuard.Entry staged = WorldReplacementEntryGuard.stage( + levelRoot, + stagedWorld, + TRANSACTION_ID + ); + + assertEquals(Set.of(FIRST_PLAYER), staged.pendingPlayers()); + writePlayer(levelRoot, SECOND_PLAYER, ".dat"); + + WorldReplacementEntryGuard.Entry refreshed = WorldReplacementEntryGuard.refreshPlayers( + levelRoot, + stagedWorld, + TRANSACTION_ID + ); + + assertEquals(Set.of(FIRST_PLAYER, SECOND_PLAYER), refreshed.pendingPlayers()); + assertEquals(refreshed, WorldReplacementEntryGuard.load(stagedWorld).orElseThrow()); + } + + @Test + public void keepsFinalReceiptUntilSafeSpawnAllowsMarkerRetirement() throws Exception { + Path levelRoot = temporaryFolder.newFolder("level-retire").toPath(); + Path stagedWorld = temporaryFolder.newFolder("stage-retire").toPath(); + writePlayer(levelRoot, FIRST_PLAYER, ".dat"); + WorldReplacementEntryGuard.stage(levelRoot, stagedWorld, TRANSACTION_ID); + + assertFalse(WorldReplacementEntryGuard.retireIfEmpty(stagedWorld, TRANSACTION_ID)); + + Optional completed = WorldReplacementEntryGuard.completePlayer( + stagedWorld, + TRANSACTION_ID, + FIRST_PLAYER + ); + + assertTrue(completed.isPresent()); + assertTrue(completed.orElseThrow().pendingPlayers().isEmpty()); + assertTrue(Files.isRegularFile(marker(stagedWorld))); + assertTrue(WorldReplacementEntryGuard.retireIfEmpty(stagedWorld, TRANSACTION_ID)); + assertFalse(Files.exists(marker(stagedWorld))); + } + + @Test + public void rejectsAReceiptFromAnotherTransactionWithoutChangingTheMarker() throws Exception { + Path levelRoot = temporaryFolder.newFolder("level-mismatch").toPath(); + Path stagedWorld = temporaryFolder.newFolder("stage-mismatch").toPath(); + writePlayer(levelRoot, FIRST_PLAYER, ".dat"); + WorldReplacementEntryGuard.stage(levelRoot, stagedWorld, TRANSACTION_ID); + + IOException failure = assertThrows( + IOException.class, + () -> WorldReplacementEntryGuard.completePlayer( + stagedWorld, + OTHER_TRANSACTION_ID, + FIRST_PLAYER + ) + ); + + assertTrue(failure.getMessage().contains("another transaction")); + assertEquals( + Set.of(FIRST_PLAYER), + WorldReplacementEntryGuard.load(stagedWorld).orElseThrow().pendingPlayers() + ); + } + + private static void writePlayer(Path levelRoot, UUID playerId, String suffix) throws IOException { + Path playerData = levelRoot.resolve("players/data"); + Files.createDirectories(playerData); + Files.writeString(playerData.resolve(playerId + suffix), "player"); + } + + private static Path marker(Path worldDirectory) { + return worldDirectory.resolve("iris").resolve(WorldReplacementEntryGuard.MARKER_NAME); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/MantleHeapPressureTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/MantleHeapPressureTest.java new file mode 100644 index 000000000..93b74fe71 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/MantleHeapPressureTest.java @@ -0,0 +1,254 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.pregenerator; + +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import javax.management.MBeanServer; +import javax.management.ObjectName; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class MantleHeapPressureTest { + private static final MantleHeapPressure.PanicGcPolicy POLICY = + new MantleHeapPressure.PanicGcPolicy(10_000L, 60_000L, 60_000L, 240_000L); + + @Test + public void normalPressureNeverRequestsEitherGcPath() { + ReclaimerFixture fixture = new ReclaimerFixture(0); + + fixture.reclaimer.request(0.91D); + fixture.clock.addAndGet(120_000L); + fixture.reclaimer.request(0.91D); + + assertEquals(0, fixture.explicitCalls.get()); + assertEquals(0, fixture.diagnosticCalls.get()); + } + + @Test + public void sustainedPanicInvokesDiagnosticOnlyAfterNormalReclaimGrace() { + ReclaimerFixture fixture = new ReclaimerFixture(0); + + fixture.reclaimer.request(0.97D); + fixture.clock.set(9_999L); + fixture.reclaimer.request(0.97D); + fixture.clock.set(10_000L); + fixture.reclaimer.request(0.97D); + fixture.clock.set(69_999L); + fixture.reclaimer.request(0.99D); + + assertEquals(1, fixture.explicitCalls.get()); + assertEquals(1, fixture.diagnosticCalls.get()); + assertEquals(List.of(0.97D), fixture.diagnosticFractions); + } + + @Test + public void recoveredEpisodeResetsButDiagnosticCooldownStillApplies() { + ReclaimerFixture fixture = new ReclaimerFixture(0); + + fixture.reclaimer.request(0.97D); + fixture.clock.set(10_000L); + fixture.reclaimer.request(0.97D); + fixture.reclaimer.resetEpisode(); + fixture.clock.set(20_000L); + fixture.reclaimer.request(0.97D); + fixture.clock.set(30_000L); + fixture.reclaimer.request(0.97D); + fixture.clock.set(70_000L); + fixture.reclaimer.request(0.97D); + + assertEquals(2, fixture.explicitCalls.get()); + assertEquals(2, fixture.diagnosticCalls.get()); + } + + @Test + public void highWaterEpisodeDiagnosesAfterNormalReclaimLeavesHeapBelowHighWater() { + ReclaimerFixture fixture = new ReclaimerFixture(0); + + fixture.reclaimer.request(0.93D); + fixture.clock.set(9_999L); + fixture.reclaimer.request(0.91D); + fixture.clock.set(10_000L); + fixture.reclaimer.request(0.91D); + + assertEquals(1, fixture.explicitCalls.get()); + assertEquals(1, fixture.diagnosticCalls.get()); + assertEquals(List.of(0.91D), fixture.diagnosticFractions); + } + + @Test + public void successfulDiagnosticRetriesAfterCooldownWhenPressureRemainsHigh() { + ReclaimerFixture fixture = new ReclaimerFixture(0); + + fixture.reclaimer.request(0.97D); + fixture.clock.set(10_000L); + fixture.reclaimer.request(0.91D); + fixture.clock.set(69_999L); + fixture.reclaimer.request(0.91D); + fixture.clock.set(70_000L); + fixture.reclaimer.request(0.91D); + + assertEquals(1, fixture.explicitCalls.get()); + assertEquals(2, fixture.diagnosticCalls.get()); + assertEquals(List.of(0.91D, 0.91D), fixture.diagnosticFractions); + } + + @Test + public void failedDiagnosticRetriesAfterBackoffWithinSameEpisode() { + ReclaimerFixture fixture = new ReclaimerFixture(1); + + fixture.reclaimer.request(0.97D); + fixture.clock.set(10_000L); + fixture.reclaimer.request(0.91D); + fixture.clock.set(69_999L); + fixture.reclaimer.request(0.91D); + fixture.clock.set(70_000L); + fixture.reclaimer.request(0.91D); + + assertEquals(1, fixture.explicitCalls.get()); + assertEquals(2, fixture.diagnosticCalls.get()); + assertEquals(1, fixture.failures.size()); + } + + @Test + public void sustainedSubHighPressureReleasesAfterBoundedHysteresis() { + AtomicLong clock = new AtomicLong(1_000L); + AtomicInteger releases = new AtomicInteger(); + MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate( + 0.92D, + 0.82D, + 60_000L, + clock::get, + releases::incrementAndGet); + + assertEquals(true, gate.update(0.93D)); + assertEquals(true, gate.update(0.89D)); + clock.set(60_999L); + assertEquals(true, gate.update(0.89D)); + clock.set(61_000L); + assertEquals(false, gate.update(0.89D)); + assertEquals(1, releases.get()); + assertEquals(false, gate.update(0.89D)); + } + + @Test + public void renewedHighPressureRestartsHysteresisAndCanReengageAfterRelease() { + AtomicLong clock = new AtomicLong(5_000L); + AtomicInteger releases = new AtomicInteger(); + MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate( + 0.92D, + 0.82D, + 60_000L, + clock::get, + releases::incrementAndGet); + + assertEquals(true, gate.update(0.93D)); + assertEquals(true, gate.update(0.88D)); + clock.set(64_999L); + assertEquals(true, gate.update(0.93D)); + assertEquals(true, gate.update(0.88D)); + clock.set(124_998L); + assertEquals(true, gate.update(0.88D)); + clock.set(124_999L); + assertEquals(false, gate.update(0.88D)); + assertEquals(1, releases.get()); + assertEquals(true, gate.update(0.92D)); + } + + @Test + public void lowWaterReleasesImmediately() { + AtomicLong clock = new AtomicLong(); + AtomicInteger releases = new AtomicInteger(); + MantleHeapPressure.HeapPressureGate gate = new MantleHeapPressure.HeapPressureGate( + 0.92D, + 0.82D, + 60_000L, + clock::get, + releases::incrementAndGet); + + assertEquals(true, gate.update(0.95D)); + assertEquals(false, gate.update(0.82D)); + assertEquals(1, releases.get()); + } + + @Test + public void diagnosticInvocationUsesCurrentJvmCommandMBean() throws Exception { + MBeanServer server = mock(MBeanServer.class); + when(server.isRegistered(any(ObjectName.class))).thenReturn(true); + + MantleHeapPressure.invokeHotSpotDiagnosticGc(server); + + ObjectName name = new ObjectName("com.sun.management:type=DiagnosticCommand"); + ArgumentCaptor parameters = ArgumentCaptor.forClass(Object[].class); + ArgumentCaptor signature = ArgumentCaptor.forClass(String[].class); + verify(server).invoke( + eq(name), + eq("gcRun"), + parameters.capture(), + signature.capture()); + assertArrayEquals(new Object[0], parameters.getValue()); + assertArrayEquals(new String[0], signature.getValue()); + } + + @Test + public void unsupportedJvmFailsBeforeInvokingDiagnosticCommand() throws Exception { + MBeanServer server = mock(MBeanServer.class); + when(server.isRegistered(any(ObjectName.class))).thenReturn(false); + + assertThrows(UnsupportedOperationException.class, () -> MantleHeapPressure.invokeHotSpotDiagnosticGc(server)); + verify(server, never()).invoke(any(ObjectName.class), any(), any(), any()); + } + + private static final class ReclaimerFixture { + private final AtomicLong clock = new AtomicLong(); + private final AtomicInteger explicitCalls = new AtomicInteger(); + private final AtomicInteger diagnosticCalls = new AtomicInteger(); + private final List diagnosticFractions = new ArrayList<>(); + private final List failures = new ArrayList<>(); + private final MantleHeapPressure.PanicGcReclaimer reclaimer; + + private ReclaimerFixture(int failuresBeforeSuccess) { + MantleHeapPressure.PanicGcActions actions = new MantleHeapPressure.PanicGcActions( + clock::get, + explicitCalls::incrementAndGet, + () -> { + int call = diagnosticCalls.incrementAndGet(); + if (call <= failuresBeforeSuccess) { + throw new IllegalStateException("unsupported"); + } + }, + diagnosticFractions::add, + (String context, Throwable failure) -> failures.add(failure)); + this.reclaimer = new MantleHeapPressure.PanicGcReclaimer(POLICY, actions); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskBoundsOverflowTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskBoundsOverflowTest.java index f9b0b4233..3b879d585 100644 --- a/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskBoundsOverflowTest.java +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskBoundsOverflowTest.java @@ -9,31 +9,27 @@ import static org.junit.Assert.assertTrue; public class PregenTaskBoundsOverflowTest { @Test - public void farPositiveCenterKeepsRegionBoundsOrdered() { - PregenTask task = PregenTask.builder() - .center(new Position2(Integer.MAX_VALUE - 16, Integer.MAX_VALUE - 16)) - .radiusX(4096) - .radiusZ(4096) - .build(); + public void farPositiveCenterIsRejectedBeforeTraversal() { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> PregenTask.builder() + .center(new Position2(Integer.MAX_VALUE - 16, Integer.MAX_VALUE - 16)) + .radiusX(4096) + .radiusZ(4096) + .build()); - int[] bounds = task.regionBounds(); - - assertTrue("minX must not exceed maxX", bounds[0] <= bounds[2]); - assertTrue("minZ must not exceed maxZ", bounds[1] <= bounds[3]); + assertTrue(failure.getMessage().contains("coordinate limit")); } @Test - public void farNegativeCenterKeepsRegionBoundsOrdered() { - PregenTask task = PregenTask.builder() - .center(new Position2(Integer.MIN_VALUE + 16, Integer.MIN_VALUE + 16)) - .radiusX(4096) - .radiusZ(4096) - .build(); + public void farNegativeCenterIsRejectedBeforeTraversal() { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> PregenTask.builder() + .center(new Position2(Integer.MIN_VALUE + 16, Integer.MIN_VALUE + 16)) + .radiusX(4096) + .radiusZ(4096) + .build()); - int[] bounds = task.regionBounds(); - - assertTrue("minX must not exceed maxX", bounds[0] <= bounds[2]); - assertTrue("minZ must not exceed maxZ", bounds[1] <= bounds[3]); + assertTrue(failure.getMessage().contains("coordinate limit")); } @Test @@ -49,11 +45,11 @@ public class PregenTaskBoundsOverflowTest { } @Test - public void worldLimitRadiusIsAccepted() { + public void exactWorldLimitRadiusIsAccepted() { PregenTask task = PregenTask.builder() .center(new Position2(0, 0)) - .radiusX(30_000_000) - .radiusZ(30_000_000) + .radiusX(PregenTask.MAX_WORLD_BLOCK) + .radiusZ(PregenTask.MAX_WORLD_BLOCK) .build(); int[] bounds = task.regionBounds(); @@ -62,6 +58,30 @@ public class PregenTaskBoundsOverflowTest { assertEquals(58594, bounds[2]); } + @Test + public void oneBlockPastWorldLimitIsRejectedOnEveryEdge() { + int limit = PregenTask.MAX_WORLD_BLOCK; + + assertWorldLimitFailure(limit, 0, 1, 1); + assertWorldLimitFailure(-limit, 0, 1, 1); + assertWorldLimitFailure(0, limit, 1, 1); + assertWorldLimitFailure(0, -limit, 1, 1); + } + + @Test + public void offsetAreaEndingExactlyAtWorldLimitIsAccepted() { + int radius = 1000; + PregenTask task = PregenTask.builder() + .center(new Position2(PregenTask.MAX_WORLD_BLOCK - radius, -PregenTask.MAX_WORLD_BLOCK + radius)) + .radiusX(radius) + .radiusZ(radius) + .build(); + + int[] bounds = task.regionBounds(); + assertTrue(bounds[0] <= bounds[2]); + assertTrue(bounds[1] <= bounds[3]); + } + @Test public void ordinaryBoundsAreUnchanged() { PregenTask task = PregenTask.builder() @@ -87,4 +107,14 @@ public class PregenTaskBoundsOverflowTest { assertEquals("bounds[" + index + "]", expected[index], actual[index]); } } + + private static void assertWorldLimitFailure(int centerX, int centerZ, int radiusX, int radiusZ) { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> PregenTask.builder() + .center(new Position2(centerX, centerZ)) + .radiusX(radiusX) + .radiusZ(radiusZ) + .build()); + assertTrue(failure.getMessage().contains("coordinate limit")); + } } diff --git a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java index f5d0cbf94..3857fc5ba 100644 --- a/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java +++ b/core/src/test/java/art/arcane/iris/core/runtime/WorldRuntimeControlServiceSafeEntryTest.java @@ -4,18 +4,31 @@ import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import org.bukkit.HeightMap; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.World; import org.bukkit.block.Block; +import org.bukkit.block.data.Waterlogged; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.VoxelShape; import org.junit.Test; -import org.mockito.Mockito; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; public class WorldRuntimeControlServiceSafeEntryTest { + private static final BoundingBox FULL_BLOCK = new BoundingBox(0D, 0D, 0D, 1D, 1D, 1D); + @Test public void resolvesStudioEntryAnchorFromGeneratorInsteadOfMutableWorldSpawn() { World world = mock(World.class); @@ -47,21 +60,180 @@ public class WorldRuntimeControlServiceSafeEntryTest { } @Test - public void resolvesSafeEntryImmediatelyWhenColumnIsAllWater() { - World world = mock(World.class); - Block stub = mock(Block.class, Mockito.RETURNS_DEEP_STUBS); - doReturn(-64).when(world).getMinHeight(); - doReturn(320).when(world).getMaxHeight(); - doReturn(true).when(world).isChunkLoaded(0, 0); - doReturn(62).when(world).getHighestBlockYAt(0, 0); - doReturn(62).when(world).getHighestBlockYAt(0, 0, HeightMap.MOTION_BLOCKING_NO_LEAVES); - doReturn(stub).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); + public void resolvesEntryAboveDryCollisionSupportingFloor() { + World world = loadedWorld(0, 0); + Block stone = block(Material.STONE, false, false, FULL_BLOCK); + Block air = block(Material.AIR, false, true); + doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES)); + doAnswer(invocation -> { + int y = invocation.getArgument(1); + return y == 62 ? stone : air; + }).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); Location source = new Location(world, 0.5D, 62D, 0.5D); Location result = WorldRuntimeControlService.findTopSafeLocation(world, source); - assertNotNull("Safe entry must resolve to a non-null location even for water-only columns", result); + assertNotNull(result); assertEquals(63, result.getBlockY()); } + @Test + public void searchesOnlyTheOwnedChunkForNearbySolidGround() { + World world = loadedWorld(0, 0); + Block water = block(Material.WATER, true, true); + Block stone = block(Material.STONE, false, false, FULL_BLOCK); + Block air = block(Material.AIR, false, true); + doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES)); + doAnswer(invocation -> { + int x = invocation.getArgument(0); + int y = invocation.getArgument(1); + int z = invocation.getArgument(2); + if (x < 0 || x > 15 || z < 0 || z > 15) { + throw new AssertionError("Safe-entry search crossed its Folia-owned source chunk"); + } + if (x == 1 && z == 0) { + return y == 62 ? stone : air; + } + return water; + }).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); + + Location source = new Location(world, 0.5D, 63D, 0.5D); + Location result = WorldRuntimeControlService.findTopSafeLocation(world, source); + + assertNotNull(result); + assertEquals(1, result.getBlockX()); + assertEquals(63, result.getBlockY()); + assertEquals(0, result.getBlockZ()); + } + + @Test + public void rejectsFluidHazardousAndCollisionBlockedCandidates() { + World world = loadedWorld(0, 0); + Block water = block(Material.WATER, true, true); + Block air = block(Material.AIR, false, true); + Block stone = block(Material.STONE, false, false, FULL_BLOCK); + Block leaves = block(Material.OAK_LEAVES, false, false, FULL_BLOCK); + Block powderSnow = block(Material.POWDER_SNOW, false, true); + Block magma = block(Material.MAGMA_BLOCK, false, false, FULL_BLOCK); + Block cactus = block(Material.CACTUS, false, false, FULL_BLOCK); + Block cobweb = block(Material.COBWEB, false, true, FULL_BLOCK); + Block fence = block(Material.OAK_FENCE, false, false, + new BoundingBox(0.375D, 0D, 0.375D, 0.625D, 1.5D, 0.625D)); + Block waterloggedSlab = waterloggedBlock( + Material.OAK_SLAB, + new BoundingBox(0D, 0D, 0D, 1D, 0.5D, 1D) + ); + doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES)); + doAnswer(invocation -> { + int x = invocation.getArgument(0); + int y = invocation.getArgument(1); + int z = invocation.getArgument(2); + if (y == 62) { + if (x == 7 && z == 7) { + return leaves; + } + if (x == 7 && z == 8) { + return powderSnow; + } + if (x == 7 && z == 9) { + return magma; + } + if (x == 8 && z == 9) { + return stone; + } + if (x == 9 && z == 7) { + return waterloggedSlab; + } + if (x == 9 && z == 8) { + return cactus; + } + if (x == 9 && z == 9) { + return stone; + } + if (x == 10 && z == 10) { + return stone; + } + } + if (x == 8 && z == 9 && y == 63) { + return cobweb; + } + if (x == 9 && z == 9 && y == 63) { + return air; + } + if (x == 9 && z == 9 && y == 64) { + return fence; + } + if (x == 10 && z == 10 && (y == 63 || y == 64)) { + return air; + } + return water; + }).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); + + Location source = new Location(world, 8.5D, 63D, 8.5D); + Location result = WorldRuntimeControlService.findTopSafeLocation(world, source); + + assertNotNull(result); + assertEquals(10, result.getBlockX()); + assertEquals(63, result.getBlockY()); + assertEquals(10, result.getBlockZ()); + } + + @Test + public void returnsNullForWaterOnlyChunkAndBoundsVerticalSearch() { + World world = loadedWorld(0, 0); + Block water = block(Material.WATER, true, true); + AtomicInteger lowestReadY = new AtomicInteger(Integer.MAX_VALUE); + doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES)); + doAnswer(invocation -> { + int y = invocation.getArgument(1); + lowestReadY.accumulateAndGet(y, Math::min); + return water; + }).when(world).getBlockAt(anyInt(), anyInt(), anyInt()); + + Location source = new Location(world, 0.5D, 63D, 0.5D); + Location result = WorldRuntimeControlService.findTopSafeLocation(world, source); + + assertNull(result); + assertEquals(-1, lowestReadY.get()); + } + + @Test + public void returnsNullWithoutReadingAnUnloadedChunk() { + World world = loadedWorld(0, 0); + doReturn(false).when(world).isChunkLoaded(0, 0); + + Location source = new Location(world, 0.5D, 63D, 0.5D); + Location result = WorldRuntimeControlService.findTopSafeLocation(world, source); + + assertNull(result); + verify(world, never()).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES)); + verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt()); + } + + private static World loadedWorld(int chunkX, int chunkZ) { + World world = mock(World.class); + doReturn(-64).when(world).getMinHeight(); + doReturn(320).when(world).getMaxHeight(); + doReturn(true).when(world).isChunkLoaded(chunkX, chunkZ); + return world; + } + + private static Block block(Material material, boolean liquid, boolean passable, BoundingBox... boundingBoxes) { + Block block = mock(Block.class); + VoxelShape collisionShape = mock(VoxelShape.class); + doReturn(material).when(block).getType(); + doReturn(liquid).when(block).isLiquid(); + doReturn(passable).when(block).isPassable(); + doReturn(collisionShape).when(block).getCollisionShape(); + doReturn(List.of(boundingBoxes)).when(collisionShape).getBoundingBoxes(); + return block; + } + + private static Block waterloggedBlock(Material material, BoundingBox... boundingBoxes) { + Block block = block(material, false, false, boundingBoxes); + Waterlogged blockData = mock(Waterlogged.class); + doReturn(true).when(blockData).isWaterlogged(); + doReturn(blockData).when(block).getBlockData(); + return block; + } } diff --git a/core/src/test/java/art/arcane/iris/core/service/GlobalCacheSVCTest.java b/core/src/test/java/art/arcane/iris/core/service/GlobalCacheSVCTest.java new file mode 100644 index 000000000..49df165f1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/GlobalCacheSVCTest.java @@ -0,0 +1,64 @@ +package art.arcane.iris.core.service; + +import art.arcane.iris.core.IrisWorldStorage; +import org.bukkit.NamespacedKey; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class GlobalCacheSVCTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void cacheUsesFrozenCurrentCraftBukkitDimensionRoot() throws Exception { + File worldContainer = temporaryFolder.newFolder("server"); + File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile(); + NamespacedKey worldKey = new NamespacedKey("iris", "underworld"); + File configuredLevelRoot = Files.createDirectory( + worldContainer.toPath().resolve("world_iris_underworld") + ).toFile(); + File configuredDimensionRoot = IrisWorldStorage.dimensionRoot(configuredLevelRoot, worldKey); + Files.createDirectories(configuredDimensionRoot.toPath().resolve("iris/pack")); + + assertEquals( + configuredDimensionRoot, + GlobalCacheSVC.requireCacheDimensionRoot(worldContainer, levelRoot, worldKey) + ); + } + + @Test + public void cacheFailsClosedWhenFrozenStorageIsAmbiguous() throws Exception { + File worldContainer = temporaryFolder.newFolder("ambiguous-server"); + File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile(); + NamespacedKey worldKey = new NamespacedKey("iris", "overworld"); + Files.createDirectories(IrisWorldStorage.dimensionRoot(levelRoot, worldKey).toPath()); + File configuredLevelRoot = Files.createDirectory( + worldContainer.toPath().resolve("world_iris_overworld") + ).toFile(); + Files.createDirectories(IrisWorldStorage.dimensionRoot(configuredLevelRoot, worldKey).toPath()); + + assertThrows( + IllegalStateException.class, + () -> GlobalCacheSVC.requireCacheDimensionRoot(worldContainer, levelRoot, worldKey) + ); + } + + @Test + public void cacheFailsClosedWhenFrozenStorageIsMissing() throws Exception { + File worldContainer = temporaryFolder.newFolder("missing-server"); + File levelRoot = Files.createDirectory(worldContainer.toPath().resolve("world")).toFile(); + NamespacedKey worldKey = new NamespacedKey("iris", "overworld"); + + assertThrows( + IllegalStateException.class, + () -> GlobalCacheSVC.requireCacheDimensionRoot(worldContainer, levelRoot, worldKey) + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/tools/IrisPersistentWorldCreationContractTest.java b/core/src/test/java/art/arcane/iris/core/tools/IrisPersistentWorldCreationContractTest.java new file mode 100644 index 000000000..52b1c13b3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/tools/IrisPersistentWorldCreationContractTest.java @@ -0,0 +1,72 @@ +package art.arcane.iris.core.tools; + +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisPersistentWorldCreationContractTest { + @Test + public void productionCreateFreezesIntoCurrentPlatformStorageBeforeWorldCreation() throws Exception { + String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/tools/IrisCreator.java")); + int createStart = source.indexOf("private World createReserved("); + int createEnd = source.indexOf("static Player createTeleportTarget(", createStart); + String create = source.substring(createStart, createEnd); + + int dimensionRoot = create.indexOf("WorldCreatorCompat.persistentDimensionRoot(worldKey)"); + int levelRoot = create.indexOf("WorldCreatorCompat.persistentLevelRoot(worldKey)"); + int existingStorage = create.indexOf("Files.exists(storageRoot.toPath())"); + int freezePack = create.indexOf(".installIntoWorld(sender, resolvedDimension, dimensionRoot)"); + int persistentCreator = create.indexOf(".persistent(!studio && !benchmark)"); + int bukkitCreate = create.indexOf("INMS.get().createWorldAsync(wc, request)"); + int rollbackStorage = create.indexOf( + "rollbackWorldCreation(worldKey, world, stagedGenerator, storageRoot, bukkitRegistered, failure)" + ); + + assertTrue(dimensionRoot >= 0); + assertTrue(levelRoot > dimensionRoot); + assertTrue(existingStorage > levelRoot); + assertTrue(freezePack > existingStorage); + assertTrue(persistentCreator > freezePack); + assertTrue(bukkitCreate > persistentCreator); + assertTrue(rollbackStorage > bukkitCreate); + assertFalse(create.contains("copySeed")); + assertFalse(create.contains("level.dat")); + } + + @Test + public void persistentCreatorPreservesConfiguredNameAndCanonicalIdentity() throws Exception { + String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java")); + int createStart = source.indexOf("public WorldCreator create()"); + int createEnd = source.indexOf("private World.Environment findEnvironment()", createStart); + String create = source.substring(createStart, createEnd); + + int persistentCreator = create.indexOf("WorldCreatorCompat.ofPersistentKey(worldKey)"); + int persistentStorage = create.indexOf("WorldCreatorCompat.persistentDimensionRoot(worldKey)"); + int canonicalIdentity = create.indexOf(".platformIdentity(worldKey.toString())"); + int configuredName = create.indexOf(".name(creator.name())"); + int exactPack = create.indexOf("new File(w.worldFolder(), \"iris/pack\")"); + + assertTrue(persistentCreator >= 0); + assertTrue(persistentStorage > persistentCreator); + assertTrue(canonicalIdentity > persistentStorage); + assertTrue(configuredName > canonicalIdentity); + assertTrue(exactPack > configuredName); + } + + @Test + public void publicBukkitBackendRebuildKeepsExactFallbackWorldName() throws Exception { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleRequest.java" + )); + int rebuildStart = source.indexOf("public WorldCreator toWorldCreator()"); + int rebuildEnd = source.indexOf("return creator;", rebuildStart); + String rebuild = source.substring(rebuildStart, rebuildEnd); + + assertTrue(rebuild.contains("WorldCreatorCompat.ofKey(worldKey, worldName)")); + assertFalse(rebuild.contains("WorldCreatorCompat.ofKey(worldKey)")); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/EngineRuntimePublicationContractTest.java b/core/src/test/java/art/arcane/iris/engine/EngineRuntimePublicationContractTest.java new file mode 100644 index 000000000..a1cdb8ede --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/EngineRuntimePublicationContractTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.engine; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; + +public class EngineRuntimePublicationContractTest { + @Test + public void worldManagerStartsOnlyAfterTheRuntimeSessionIsReady() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java")); + int publishStart = source.indexOf("void publishRuntime("); + int publishEnd = source.indexOf("private void scheduleRuntimeTasks", publishStart); + String publish = source.substring(publishStart, publishEnd); + + assertBefore(publish, "engine.runtime = next", "next.worldManager().start()"); + assertBefore(publish, "activateNextSession()", "next.worldManager().start()"); + assertBefore(publish, "engine.lifecycleState = LifecycleState.RUNNING", "next.worldManager().start()"); + assertBefore(publish, "engine.getClosing().set(false)", "next.worldManager().start()"); + assertBefore(publish, "openBackgroundTaskAdmission()", "next.worldManager().start()"); + } + + @Test + public void failedManagerStartClosesAdmissionBeforeRuntimeCleanup() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java")); + int publishStart = source.indexOf("void publishRuntime("); + int publishEnd = source.indexOf("private void scheduleRuntimeTasks", publishStart); + String publish = source.substring(publishStart, publishEnd); + + assertBefore(publish, "engine.getClosing().set(true)", "closeRuntime(next, e)"); + assertBefore(publish, "closeBackgroundTaskAdmission()", "closeRuntime(next, e)"); + assertBefore(publish, "sealAndAwait(", "closeRuntime(next, e)"); + } + + private static void assertBefore(String source, String first, String second) { + int firstIndex = source.indexOf(first); + int secondIndex = source.indexOf(second); + assertTrue("Missing source contract token: " + first, firstIndex >= 0); + assertTrue("Missing source contract token: " + second, secondIndex >= 0); + assertTrue(first + " must occur before " + second, firstIndex < secondIndex); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java index 61fdcd2ef..e7217db79 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCarvingResolverParityTest.java @@ -14,13 +14,25 @@ import org.junit.BeforeClass; import org.junit.Test; import org.mockito.invocation.InvocationOnMock; +import java.lang.ref.WeakReference; +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; import java.util.HashMap; import java.util.List; import java.util.Locale; 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 java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import static org.mockito.Answers.CALLS_REAL_METHODS; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -136,6 +148,173 @@ public class IrisDimensionCarvingResolverParityTest { } } + @Test + public void sharedResolverStateNeverCarriesDimensionEntriesAcrossEngines() { + Fixture first = createFixture(); + Fixture second = createMixedDepthFixture(); + IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); + + IrisDimensionCarvingEntry firstExpected = legacyResolveRootEntry(first.engine, 80); + IrisDimensionCarvingEntry secondExpected = legacyResolveRootEntry(second.engine, 80); + + assertSame(firstExpected, IrisDimensionCarvingResolver.resolveRootEntry(first.engine, 80, state)); + assertSame(secondExpected, IrisDimensionCarvingResolver.resolveRootEntry(second.engine, 80, state)); + assertSame(firstExpected, IrisDimensionCarvingResolver.resolveRootEntry(first.engine, 80, state)); + } + + @Test + public void threadLocalResolverNeverCarriesDimensionEntriesAcrossEngines() { + Fixture first = createFixture(); + Fixture second = createMixedDepthFixture(); + int worldY = 83; + + IrisDimensionCarvingEntry firstExpected = legacyResolveRootEntry(first.engine, worldY); + IrisDimensionCarvingEntry secondExpected = legacyResolveRootEntry(second.engine, worldY); + + assertSame(firstExpected, IrisDimensionCarvingResolver.resolveRootEntry(first.engine, worldY)); + assertSame(secondExpected, IrisDimensionCarvingResolver.resolveRootEntry(second.engine, worldY)); + } + + @Test + public void threadLocalResolverInvalidatesWhenTheSameEnginePublishesReplacementData() { + Fixture first = createFixture(); + Fixture replacement = createMixedDepthFixture(); + AtomicReference dimension = new AtomicReference<>(first.engine.getDimension()); + AtomicReference data = new AtomicReference<>(first.engine.getData()); + Engine engine = mock(Engine.class, CALLS_REAL_METHODS); + doAnswer((InvocationOnMock invocation) -> dimension.get()).when(engine).getDimension(); + doAnswer((InvocationOnMock invocation) -> data.get()).when(engine).getData(); + + int worldY = 83; + IrisDimensionCarvingEntry firstExpected = legacyResolveRootEntry(first.engine, worldY); + IrisDimensionCarvingEntry replacementExpected = legacyResolveRootEntry(replacement.engine, worldY); + assertSame(firstExpected, IrisDimensionCarvingResolver.resolveRootEntry(engine, worldY)); + + dimension.set(replacement.engine.getDimension()); + data.set(replacement.engine.getData()); + assertSame(replacementExpected, IrisDimensionCarvingResolver.resolveRootEntry(engine, worldY)); + } + + @Test + public void resolverStateUsesWeakRuntimeIdentityBindings() throws NoSuchFieldException { + Field engineIdentity = IrisDimensionCarvingResolver.State.class.getDeclaredField("engineIdentity"); + Field dimensionIdentity = IrisDimensionCarvingResolver.State.class.getDeclaredField("dimensionIdentity"); + Field dataIdentity = IrisDimensionCarvingResolver.State.class.getDeclaredField("dataIdentity"); + + assertSame(WeakReference.class, engineIdentity.getType()); + assertSame(WeakReference.class, dimensionIdentity.getType()); + assertSame(WeakReference.class, dataIdentity.getType()); + } + + @Test(timeout = 20_000L) + public void longLivedWorkerDoesNotRetainPopulatedThreadLocalStateOrEngine() throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(); + CountDownLatch blockerStarted = new CountDownLatch(1); + CountDownLatch releaseBlocker = new CountDownLatch(1); + try { + LifetimeReferences references = executor.submit(this::populateThreadLocalLifetimeReferences).get(); + Future blocker = executor.submit(() -> { + blockerStarted.countDown(); + releaseBlocker.await(); + return null; + }); + assertTrue(blockerStarted.await(5L, TimeUnit.SECONDS)); + + awaitCollection(references); + + releaseBlocker.countDown(); + blocker.get(5L, TimeUnit.SECONDS); + } finally { + releaseBlocker.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + private LifetimeReferences populateThreadLocalLifetimeReferences() throws Exception { + RetainedFixture fixture = createRetainedFixture(); + assertNotNull(IrisDimensionCarvingResolver.resolveRootEntry(fixture.engine(), 80)); + + Field threadStateField = IrisDimensionCarvingResolver.class.getDeclaredField("THREAD_STATE"); + threadStateField.setAccessible(true); + ThreadLocal threadState = (ThreadLocal) threadStateField.get(null); + Object value = threadState.get(); + assertTrue(value instanceof WeakReference); + @SuppressWarnings("unchecked") + WeakReference state = + (WeakReference) value; + IrisDimensionCarvingResolver.State populatedState = state.get(); + assertNotNull(populatedState); + Field biomeCacheField = IrisDimensionCarvingResolver.State.class.getDeclaredField("biomeCache"); + biomeCacheField.setAccessible(true); + assertFalse(((Map) biomeCacheField.get(populatedState)).isEmpty()); + return new LifetimeReferences( + state, + new WeakReference<>(fixture.engine())); + } + + private static void awaitCollection(LifetimeReferences references) throws InterruptedException { + for (int attempt = 0; attempt < 100; attempt++) { + System.gc(); + if (references.state().get() == null + && references.engine().get() == null) { + return; + } + byte[] pressure = new byte[1_048_576]; + pressure[0] = (byte) attempt; + Thread.sleep(10L); + } + assertTrue("Thread-local State was retained", references.state().get() == null); + assertTrue("Engine was retained", references.engine().get() == null); + } + + @Test + public void explicitStateRemainsStronglyCallerOwned() throws Exception { + RetainedFixture fixture = createRetainedFixture(); + IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); + assertSame(fixture.entry(), IrisDimensionCarvingResolver.resolveRootEntry( + fixture.engine(), 80, state)); + + Field rootEntriesField = IrisDimensionCarvingResolver.State.class + .getDeclaredField("rootEntriesByWorldY"); + rootEntriesField.setAccessible(true); + System.gc(); + + assertSame(fixture.entry(), ((Map) rootEntriesField.get(state)).get(80)); + } + + private RetainedFixture createRetainedFixture() { + IrisData data = mock(IrisData.class); + IrisBiome biome = new IrisBiome(); + biome.setLoader(data); + + @SuppressWarnings("unchecked") + ResourceLoader biomeLoader = mock(ResourceLoader.class); + doReturn(biome).when(biomeLoader).load("retained"); + doReturn(biomeLoader).when(data).getBiomeLoader(); + + IrisDimensionCarvingEntry entry = buildEntry( + "retained", "retained", new IrisRange(-64, 320), 0, List.of()); + KList carvingEntries = new KList<>(); + carvingEntries.add(entry); + + IrisDimension dimension = new IrisDimension(); + dimension.setCarving(carvingEntries); + + Engine engine = (Engine) Proxy.newProxyInstance( + Engine.class.getClassLoader(), + new Class[]{Engine.class}, + (proxy, method, arguments) -> switch (method.getName()) { + case "getDimension" -> dimension; + case "getData" -> data; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == arguments[0]; + case "toString" -> "carving-retention-test-engine"; + default -> throw new UnsupportedOperationException(method.toString()); + }); + return new RetainedFixture(engine, entry); + } + private Fixture createFixture() { IrisBiome rootLowBiome = mock(IrisBiome.class); IrisBiome rootHighBiome = mock(IrisBiome.class); @@ -454,6 +633,15 @@ public class IrisDimensionCarvingResolverParityTest { private record Fixture(Engine engine) { } + private record RetainedFixture(Engine engine, IrisDimensionCarvingEntry entry) { + } + + private record LifetimeReferences( + WeakReference state, + WeakReference engine + ) { + } + private static final class LegacyCarvingChoice implements IRare { private final IrisDimensionCarvingEntry entry; private final int rarity; diff --git a/gradle.properties b/gradle.properties index c0854b544..68c75295d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,6 +28,6 @@ minecraftVersion=26.2 # Bukkit plugin api-version: lowest supported Minecraft release line so one artifact loads on 26.1.2 and 26.2 apiVersion=26.1 fabricLoaderVersion=0.19.3 -forgeVersion=26.2-65.0.4 -neoForgeVersion=26.2.0.12-beta -volmLibCoordinate=com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969 +forgeVersion=26.2-65.1.1 +neoForgeVersion=26.2.0.59 +volmLibCoordinate=com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522 diff --git a/probe/build.gradle b/probe/build.gradle index 896bcce0f..e0d68893e 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -10,7 +10,7 @@ application { } String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') - .orElse('com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969') + .orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522') .get() dependencies {