This commit is contained in:
Brian Neumann-Fopiano
2026-08-15 21:32:00 -04:00
parent ef2e1b8f58
commit fc0fdf4ce4
81 changed files with 5109 additions and 511 deletions
+30 -9
View File
@@ -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:
@@ -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<Path> 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<PaperLevelOverrides> 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);
}
}
@@ -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<String> declaredSources,
IrisImportedStructureControl importedStructures
) {
return filter(
structureSets,
scopeIndex,
declaredSources,
importedStructures,
keyIndex(structureSets));
}
static Selection filter(
List<Holder<StructureSet>> structureSets,
DatapackStructureScopeIndex scopeIndex,
Set<String> declaredSources,
IrisImportedStructureControl importedStructures,
StructureSetKeyIndex keyIndex
) {
if (scopeIndex.isEmpty()) {
return new Selection(
scaleFrequencyOverrides(structureSets, importedStructures),
scaleFrequencyOverrides(structureSets, importedStructures, keyIndex),
0,
0);
}
Map<String, Holder<StructureSet>> holdersByKey = new HashMap<>();
for (Holder<StructureSet> 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<StructureSet> holder : structureSets) {
String setKey = structureSetKey(holder);
String setKey = structureSetKey(holder, keyIndex);
boolean managedSet = setKey != null && scopeIndex.isManagedStructureSet(setKey);
Holder<StructureSet> 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<StructureSet> holder) {
Optional<ResourceKey<StructureSet>> 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<? extends Holder<StructureSet>> registeredStructureSets
) {
return StructureSetKeyIndex.create(registeredStructureSets);
}
private static String structureSetKey(
Holder<StructureSet> holder,
StructureSetKeyIndex keyIndex
) {
return keyIndex.resolve(holder)
.map(key -> key.identifier().toString())
.orElse(null);
}
private static List<StructureSet.StructureSelectionEntry> filterEntries(
@@ -122,7 +145,8 @@ final class DatapackStructureStateFilter {
Set<String> declaredSources,
Map<String, Holder<StructureSet>> holdersByKey,
Map<Holder<StructureSet>, Holder<StructureSet>> scopedByIdentity,
Set<Holder<StructureSet>> visiting
Set<Holder<StructureSet>> 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<StructureSet> 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<String> declaredSources,
Map<String, Holder<StructureSet>> holdersByKey,
Map<Holder<StructureSet>, Holder<StructureSet>> scopedByIdentity,
Set<Holder<StructureSet>> visiting
Set<Holder<StructureSet>> visiting,
StructureSetKeyIndex keyIndex
) {
Optional<StructurePlacement.ExclusionZone> currentZone = exclusionZone(placement);
if (currentZone.isEmpty()) {
@@ -192,7 +218,7 @@ final class DatapackStructureStateFilter {
}
Holder<StructureSet> 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<StructurePlacement.ExclusionZone> scopedZone = Optional.empty();
if (scopedTarget != null) {
scopedZone = Optional.of(new StructurePlacement.ExclusionZone(
@@ -217,14 +244,15 @@ final class DatapackStructureStateFilter {
private static List<Holder<StructureSet>> scaleFrequencyOverrides(
List<Holder<StructureSet>> structureSets,
IrisImportedStructureControl importedStructures
IrisImportedStructureControl importedStructures,
StructureSetKeyIndex keyIndex
) {
if (!importedStructures.hasFrequencyOverrides()) {
return structureSets;
}
Map<String, Holder<StructureSet>> holdersByKey = new HashMap<>();
for (Holder<StructureSet> 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<StructureSet> holder : structureSets) {
Holder<StructureSet> 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<StructureSet> holder,
IrisImportedStructureControl importedStructures,
Map<String, Holder<StructureSet>> holdersByKey,
Map<Holder<StructureSet>, Holder<StructureSet>> scaledByIdentity
Map<Holder<StructureSet>, Holder<StructureSet>> 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<StructureSet> holderKey = structureSetResourceKey(holder).orElseThrow(() ->
ResourceKey<StructureSet> 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<StructurePlacement.ExclusionZone> scaledZone = originalZone;
if (originalZone.isPresent()) {
Holder<StructureSet> target = canonicalHolder(
originalZone.get().otherSet(), holdersByKey);
originalZone.get().otherSet(), holdersByKey, keyIndex);
Holder<StructureSet> 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<StructureSet> holder,
IrisImportedStructureControl importedStructures,
Map<String, Holder<StructureSet>> holdersByKey
Map<String, Holder<StructureSet>> holdersByKey,
StructureSetKeyIndex keyIndex
) {
Set<Holder<StructureSet>> visited = Collections.newSetFromMap(new IdentityHashMap<>());
Holder<StructureSet> current = holder;
while (visited.add(current)) {
if (importedStructures.frequencyMultiplier(structureSetKey(current)) != 1D) {
if (importedStructures.frequencyMultiplier(
structureSetKey(current, keyIndex)) != 1D) {
return true;
}
Optional<StructurePlacement.ExclusionZone> 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<StructureSet> canonicalHolder(
Holder<StructureSet> holder,
Map<String, Holder<StructureSet>> holdersByKey
Map<String, Holder<StructureSet>> holdersByKey,
StructureSetKeyIndex keyIndex
) {
String key = structureSetKey(holder);
String key = structureSetKey(holder, keyIndex);
return key == null ? holder : holdersByKey.getOrDefault(key, holder);
}
private static Holder<StructureSet> replacementHolder(
Holder<StructureSet> original,
StructureSet replacement
StructureSet replacement,
StructureSetKeyIndex keyIndex
) {
Optional<ResourceKey<StructureSet>> key = structureSetResourceKey(original);
Optional<ResourceKey<StructureSet>> key = keyIndex.resolve(original);
if (key.isEmpty()) {
return Holder.direct(replacement);
}
@@ -326,20 +369,6 @@ final class DatapackStructureStateFilter {
return holder;
}
private static Optional<ResourceKey<StructureSet>> structureSetResourceKey(
Holder<StructureSet> holder
) {
Optional<ResourceKey<StructureSet>> 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<StructurePlacement.ExclusionZone> 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<List<StructureSet.StructureSelectionEntry>, ResourceKey<StructureSet>>
keysByEntries;
private StructureSetKeyIndex(
Map<List<StructureSet.StructureSelectionEntry>, ResourceKey<StructureSet>>
keysByEntries
) {
this.keysByEntries = keysByEntries;
}
private static StructureSetKeyIndex create(
Iterable<? extends Holder<StructureSet>> registeredStructureSets
) {
Map<List<StructureSet.StructureSelectionEntry>, ResourceKey<StructureSet>> keys =
new IdentityHashMap<>();
for (Holder<StructureSet> holder : registeredStructureSets) {
Optional<ResourceKey<StructureSet>> key = holder.unwrapKey();
if (key.isEmpty()) {
continue;
}
List<StructureSet.StructureSelectionEntry> entries = holder.value().structures();
ResourceKey<StructureSet> 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<ResourceKey<StructureSet>> resolve(Holder<StructureSet> holder) {
Optional<ResourceKey<StructureSet>> key = holder.unwrapKey();
if (key.isPresent()) {
return key;
}
return Optional.ofNullable(keysByEntries.get(holder.value().structures()));
}
}
record Selection(
List<Holder<StructureSet>> structureSets,
int retainedManagedSets,
@@ -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<Biome, Object> baseBiomeCache = new KMap<>();
private volatile DataVersion dataVersion;
private final BlockData AIR = Material.AIR.createBlockData();
private final AtomicCache<MCAIdMap<net.minecraft.world.level.biome.Biome>> biomeMapCache = new AtomicCache<>();
private final AtomicBoolean injected = new AtomicBoolean();
private volatile ResettableClassFileTransformer levelStorageAccessTransformer;
private volatile ResettableClassFileTransformer serverLevelTransformer;
private final AtomicCache<MCAIdMapper<BlockState>> registryCache = new AtomicCache<>();
private final AtomicCache<MCAPalette<BlockState>> 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<StructureSet> 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<Path> 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<PaperLevelOverrides> 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<LevelStem> 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<Level> dimensionKey,
@Advice.Argument(value = 4, readOnly = false) ResourceKey<Level> 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);
@@ -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)) {
@@ -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<Material, List<BlockProperty>> 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<Material, List<BlockProperty>> 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);
@@ -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<StructureSet> 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<StructureSet.StructureSelectionEntry> entries = List.of(
new StructureSet.StructureSelectionEntry(
structureHolder("minecraft:pillager_outpost"), 1));
Holder<StructureSet> registered = new KeyedHolder<>(key, new StructureSet(
entries,
new RandomSpreadStructurePlacement(
34, 8, RandomSpreadType.LINEAR, 10387312)));
Holder<StructureSet> 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<StructureSet> 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<String> structureKeys,
List<String> structureSetKeys
@@ -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<LevelStem> 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<Level> 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);
}
}
@@ -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;
}
+1 -1
View File
@@ -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 {
@@ -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;
@@ -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<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
try {
String worldName = IrisWorldStorage.logicalName(worldKey);
Iris.info("Loading World: %s | Generator: %s", worldName, dimension);
ChunkGenerator generator = plugin.getDefaultWorldGenerator(worldName, dimension);
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension);
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);
}
}
}
@@ -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."));
}
}
@@ -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(
@@ -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."
));
}
}
@@ -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<DeleteTarget> targets = new ArrayList<>(3);
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(storedName)) {
NamespacedKey key = IrisWorldStorage.managedKeyFromName(familyWorldName, levelRoot.getName());
Path path = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key).toPath();
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 {
@@ -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<UUID> cleanupInFlight = new HashSet<>();
private final Set<UUID> verificationInFlight = new HashSet<>();
private final ConcurrentHashMap<UUID, UUID> pendingEntryAcknowledgements = new ConcurrentHashMap<>();
private volatile WorldReplacementEntryGuard.Entry overworldEntryGuard;
private volatile Path overworldEntryWorldDirectory;
private volatile CompletableFuture<Location> 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<String> failures = new ArrayList<>();
ArrayList<String> 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<Transaction> 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<Location> 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<Chunk> 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<Location> applyOverworldSpawn(Location safeEntry) {
Location requiredSafeEntry = Objects.requireNonNull(safeEntry, "safeEntry").clone();
World world = Objects.requireNonNull(requiredSafeEntry.getWorld(), "safeEntry.world");
CompletableFuture<Location> 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<Location> persistOverworldSpawn(Location safeEntry) {
Location requiredSafeEntry = Objects.requireNonNull(safeEntry, "safeEntry").clone();
World world = Objects.requireNonNull(requiredSafeEntry.getWorld(), "safeEntry.world");
CompletableFuture<Location> 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<Boolean> 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<Chunk> 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<Boolean> inspectLoadedLoginCollision(Location location) {
World world = Objects.requireNonNull(location.getWorld(), "location.world");
CompletableFuture<Boolean> 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<WorldReplacementEntryGuard.Entry> 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<Location> 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<Location> 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<Location> 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<WorldReplacementEntryGuard.Entry> 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);
@@ -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<String> {
@@ -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"));
@@ -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");
@@ -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<Location> 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<Location> 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,
+1 -1
View File
@@ -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<String> irisArtifactName = { String platform, String targetVersion ->
return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
}
+2 -2
View File
@@ -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<String> loaderDisplayVersion = { String loaderVersion ->
String coordinatePrefix = "${minecraftVersion}-"
if (loaderVersion.startsWith(coordinatePrefix)) {
@@ -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);
}
}
@@ -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("===============================================================");
}
@@ -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;
}
@@ -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<BlockPos, BlockState> visitor) {
chunk.findBlocks(PoiTypes::hasPoi, visitor);
}
private static String nativeStructureBatchContext(List<NativePlacementGroup> placementGroups) {
if (placementGroups.isEmpty()) {
return "<no resolved native structures>";
@@ -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<List<Structure>> structuresByStep(Registry<Structure> registry) {
@@ -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<Biome> fallbackBiome;
private final BiomeManager biomeManager;
private final AABB generationBounds;
private final LevelTickAccess<Block> blockTicks;
private final LevelTickAccess<Fluid> fluidTicks;
private final Long2LongOpenHashMap terrainHeights;
private final Long2ObjectOpenHashMap<ChunkAccess> 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<String> 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<Block> getBlockTicks() {
return blockTicks;
}
@Override
public LevelTickAccess<Fluid> 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<GameEvent> 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<Biome> getNoiseBiome(int quartX, int quartY, int quartZ) {
return getUncachedNoiseBiome(quartX, quartY, quartZ);
}
@Override
public Holder<Biome> 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 <T extends BlockEntity> Optional<T> getBlockEntity(
BlockPos position, BlockEntityType<T> 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<Entity> getEntities(Entity source, AABB area, Predicate<? super Entity> predicate) {
AABB boundedArea = boundedArea(area);
return boundedArea == null ? List.of() : delegate.getEntities(source, boundedArea, predicate);
}
@Override
public <T extends Entity> List<T> getEntities(
EntityTypeTest<Entity, T> type,
AABB area, Predicate<? super T> predicate) {
AABB boundedArea = boundedArea(area);
return boundedArea == null ? List.of() : delegate.getEntities(type, boundedArea, predicate);
}
@Override
public List<? extends Player> players() {
return delegate.players();
}
@Override
public boolean isStateAtPosition(BlockPos position, Predicate<BlockState> predicate) {
return predicate.test(getBlockState(position));
}
@Override
public boolean isFluidAtPosition(BlockPos position, Predicate<FluidState> 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<T> implements LevelTickAccess<T> {
private final LevelTickAccess<T> delegate;
private final Predicate<BlockPos> writable;
private BoundedTickAccess(LevelTickAccess<T> delegate, Predicate<BlockPos> writable) {
this.delegate = delegate;
this.writable = writable;
}
@Override
public void schedule(ScheduledTick<T> 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);
}
}
}
@@ -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) {
@@ -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;
}
@@ -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) {
@@ -73,6 +73,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
private final AtomicBoolean finalSaveCompleted = new AtomicBoolean(false);
private final AtomicReference<FinalSaveRequest> 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) {
@@ -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<Block, List<StructureTemplate.StructureBlockInfo>> cache;
@Inject(method = "<init>(Ljava/util/List;)V", at = @At("RETURN"))
private void iris$installConcurrentBlockCache(List<StructureTemplate.StructureBlockInfo> blocks,
CallbackInfo info) {
cache = new ConcurrentHashMap<>();
ModdedMixinFlags.markStructureTemplatePalette();
}
}
@@ -6,7 +6,8 @@
"mixins": [
"EntityPersistenceMixin",
"LivingEntityLootMixin",
"MobAwarenessMixin"
"MobAwarenessMixin",
"StructureTemplatePaletteConcurrencyMixin"
],
"injectors": {
"defaultRequire": 1
@@ -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());
@@ -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<BlockPos, BlockState> 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<PoiType> 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<Runnable> 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<Runnable> serverQueue,
PoiSection section,
BlockPos position,
BlockState oldState,
BlockState newState,
AtomicInteger missingRemovals) {
Optional<Holder<PoiType>> oldType = PoiTypes.forState(oldState);
Optional<Holder<PoiType>> 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<BlockState> blockStrategy = Strategy.createForBlockStates(Block.BLOCK_STATE_REGISTRY);
Codec<PalettedContainer<BlockState>> 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<Biome> biomeHolder = Holder.direct(biome);
IdMapper<Holder<Biome>> biomeIds = new IdMapper<>(1);
biomeIds.add(biomeHolder);
Strategy<Holder<Biome>> biomeStrategy = Strategy.createForBiomes(biomeIds);
Codec<PalettedContainerRO<Holder<Biome>>> biomeCodec = PalettedContainer.codecRO(
Biome.CODEC, biomeStrategy, biomeHolder);
return new PalettedContainerFactory(
blockStrategy,
Blocks.AIR.defaultBlockState(),
blockCodec,
biomeStrategy,
biomeHolder,
biomeCodec);
}
}
@@ -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<BlockState> loadedOnly = access.getBlockStatesIfLoaded(new AABB(
x, FLOOR_FIRST_FREE_Y, z, x, FLOOR_FIRST_FREE_Y, z)).toList();
List<BlockState> 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<Entity, Entity> 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> biome;
private final BiomeManager biomeManager;
private final RecordingTicks<Block> blockTicks;
private final RecordingTicks<Fluid> fluidTicks;
private final List<AABB> 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<T> implements LevelTickAccess<T> {
private int count;
@Override
public void schedule(ScheduledTick<T> 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;
}
}
}
@@ -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<String, Integer> cache = new ConcurrentHashMap<>();
ExecutorService executor = Executors.newFixedThreadPool(32);
CountDownLatch start = new CountDownLatch(1);
AtomicInteger mappingCalls = new AtomicInteger();
List<Future<Integer>> 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<Integer> 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<Block, List<StructureTemplate.StructureBlockInfo>> cache;"));
assertTrue(mixin.contains("@Inject(method = \"<init>(Ljava/util/List;)V\", at = @At(\"RETURN\"))"));
assertTrue(mixin.contains("cache = new ConcurrentHashMap<>();"));
}
}
@@ -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<List<Structure>> 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");
+2 -2
View File
@@ -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<String> irisArtifactName = { String platform, String targetVersion ->
return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
}
+3 -3
View File
@@ -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()
+1 -1
View File
@@ -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()
@@ -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<File> 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);
}
}
@@ -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<File> 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<File> 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<File> 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);
}
@@ -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;
}
}
}
@@ -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,
@@ -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);
@@ -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);
@@ -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)
@@ -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);
@@ -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,
@@ -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<Entry> 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<UUID> pendingPlayers = new HashSet<>(current.pendingPlayers());
pendingPlayers.addAll(discoverPlayers(requiredLevelRoot));
Entry refreshed = new Entry(requiredTransactionId, pendingPlayers);
write(requiredWorldDirectory, refreshed);
return refreshed;
}
public static Optional<Entry> 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<UUID> 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<Entry> 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<Entry> 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<UUID> 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<Entry> 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<UUID> 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<UUID> players = new HashSet<>();
try (DirectoryStream<Path> 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<UUID> 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<UUID> pendingPlayers) {
public Entry {
Objects.requireNonNull(transactionId, "transactionId");
pendingPlayers = Set.copyOf(Objects.requireNonNull(pendingPlayers, "pendingPlayers"));
}
}
}
@@ -418,9 +418,7 @@ public class IrisPregenerator {
IrisLogging.reportError(e);
}
if (MantleHeapPressure.overPanicWater()) {
MantleHeapPressure.requestPanicReclaim();
}
MantleHeapPressure.requestPanicReclaim();
}
private void checkRegion(int x, int z) {
@@ -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<String, Throwable> 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;
}
}
}
@@ -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) {
@@ -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());
}
@@ -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<Material> 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")
@@ -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();
}
@@ -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
);
}
}
@@ -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<Boolean> 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);
@@ -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);
}
@@ -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());
}
@@ -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<State> THREAD_STATE = ThreadLocal.withInitial(State::new);
private static final ThreadLocal<WeakReference<State>> 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<State> 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<Integer, IrisDimensionCarvingEntry> rootEntriesByWorldY = new HashMap<>();
private final Map<IrisDimensionCarvingEntry, ParentSelectionPlan> selectionPlans = new IdentityHashMap<>();
private final Map<IrisDimensionCarvingEntry, IrisBiome> biomeCache = new IdentityHashMap<>();
private WeakReference<Engine> engineIdentity;
private WeakReference<IrisDimension> dimensionIdentity;
private WeakReference<IrisData> dataIdentity;
private Map<String, IrisDimensionCarvingEntry> 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 {
@@ -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()));
}
@@ -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));
}
}
@@ -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<String, String> selected = IrisWorlds.filterBukkitWorldsByStorage(
levelRoot,
Map.of("world_iris_moon", "overworld")
);
assertEquals(Map.of("world_iris_moon", "overworld"), selected);
}
}
@@ -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
@@ -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");
@@ -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);
@@ -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
@@ -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")
);
@@ -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<WorldReplacementEntryGuard.Entry> 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);
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Object[]> parameters = ArgumentCaptor.forClass(Object[].class);
ArgumentCaptor<String[]> 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<Double> diagnosticFractions = new ArrayList<>();
private final List<Throwable> 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);
}
}
}
@@ -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"));
}
}
@@ -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;
}
}
@@ -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)
);
}
}
@@ -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)"));
}
}
@@ -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);
}
}
@@ -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<IrisDimension> dimension = new AtomicReference<>(first.engine.getDimension());
AtomicReference<IrisData> 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<IrisDimensionCarvingResolver.State> state =
(WeakReference<IrisDimensionCarvingResolver.State>) 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<IrisBiome> 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<IrisDimensionCarvingEntry> 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<IrisDimensionCarvingResolver.State> state,
WeakReference<Engine> engine
) {
}
private static final class LegacyCarvingChoice implements IRare {
private final IrisDimensionCarvingEntry entry;
private final int rarity;
+3 -3
View File
@@ -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
+1 -1
View File
@@ -10,7 +10,7 @@ application {
}
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
.orElse('com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969')
.orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')
.get()
dependencies {