mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
d
This commit is contained in:
@@ -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 |
|
| 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 |
|
| 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+ |
|
| 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+ |
|
| 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; requires NeoForge 26.2.0.12-beta+ |
|
| NeoForge | mod jar | 26.2 | Server worldgen + client HUD; current target is NeoForge 26.2.0.59 |
|
||||||
|
|
||||||
Java 25 is required on every platform.
|
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
|
**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`,
|
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
|
**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
|
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
|
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
|
without stopping the server. Before loading the shipping Overworld, manually place the exact
|
||||||
(height ranges) and custom biomes through the forced datapack at server start - restart once after
|
compatible Towns & Towers 26.1 and Dungeons & Taverns 5.3.0 archives in that save's `datapacks/`
|
||||||
adding a pack so worlds get its full heights and biomes; worlds created before that restart run
|
directory; modded `/iris datapack ingest` is an explanatory stub and does not install them. Restart
|
||||||
with fallback heights.
|
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
|
**Singleplayer (modded clients):** installed Iris packs appear as selectable World Types on the
|
||||||
Create New World screen; the integrated server runs the same engine.
|
Create New World screen; the integrated server runs the same engine.
|
||||||
@@ -89,7 +98,8 @@ mod is inert.
|
|||||||
|
|
||||||
## Quickstart
|
## 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):
|
Plugin (optional arguments are keyed):
|
||||||
|
|
||||||
@@ -105,6 +115,17 @@ Mod (positional arguments):
|
|||||||
/iris tp irisworldgen:myworld
|
/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
|
Pregeneration requires a radius in blocks. On the plugin, optional arguments are keyed; on modded
|
||||||
servers they are positional and composable:
|
servers they are positional and composable:
|
||||||
|
|
||||||
|
|||||||
+130
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
-70
@@ -7,7 +7,6 @@ import net.minecraft.core.Holder;
|
|||||||
import net.minecraft.core.HolderOwner;
|
import net.minecraft.core.HolderOwner;
|
||||||
import net.minecraft.core.Vec3i;
|
import net.minecraft.core.Vec3i;
|
||||||
import net.minecraft.resources.ResourceKey;
|
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.StructureSet;
|
||||||
import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement;
|
import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement;
|
||||||
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
|
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
|
||||||
@@ -41,16 +40,31 @@ final class DatapackStructureStateFilter {
|
|||||||
DatapackStructureScopeIndex scopeIndex,
|
DatapackStructureScopeIndex scopeIndex,
|
||||||
Set<String> declaredSources,
|
Set<String> declaredSources,
|
||||||
IrisImportedStructureControl importedStructures
|
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()) {
|
if (scopeIndex.isEmpty()) {
|
||||||
return new Selection(
|
return new Selection(
|
||||||
scaleFrequencyOverrides(structureSets, importedStructures),
|
scaleFrequencyOverrides(structureSets, importedStructures, keyIndex),
|
||||||
0,
|
0,
|
||||||
0);
|
0);
|
||||||
}
|
}
|
||||||
Map<String, Holder<StructureSet>> holdersByKey = new HashMap<>();
|
Map<String, Holder<StructureSet>> holdersByKey = new HashMap<>();
|
||||||
for (Holder<StructureSet> holder : structureSets) {
|
for (Holder<StructureSet> holder : structureSets) {
|
||||||
String key = structureSetKey(holder);
|
String key = structureSetKey(holder, keyIndex);
|
||||||
if (key != null) {
|
if (key != null) {
|
||||||
holdersByKey.putIfAbsent(key, holder);
|
holdersByKey.putIfAbsent(key, holder);
|
||||||
}
|
}
|
||||||
@@ -61,7 +75,7 @@ final class DatapackStructureStateFilter {
|
|||||||
int retainedManagedSets = 0;
|
int retainedManagedSets = 0;
|
||||||
int excludedManagedSets = 0;
|
int excludedManagedSets = 0;
|
||||||
for (Holder<StructureSet> holder : structureSets) {
|
for (Holder<StructureSet> holder : structureSets) {
|
||||||
String setKey = structureSetKey(holder);
|
String setKey = structureSetKey(holder, keyIndex);
|
||||||
boolean managedSet = setKey != null && scopeIndex.isManagedStructureSet(setKey);
|
boolean managedSet = setKey != null && scopeIndex.isManagedStructureSet(setKey);
|
||||||
Holder<StructureSet> scopedHolder = scopeHolder(
|
Holder<StructureSet> scopedHolder = scopeHolder(
|
||||||
holder,
|
holder,
|
||||||
@@ -69,7 +83,8 @@ final class DatapackStructureStateFilter {
|
|||||||
declaredSources,
|
declaredSources,
|
||||||
holdersByKey,
|
holdersByKey,
|
||||||
scopedByIdentity,
|
scopedByIdentity,
|
||||||
visiting);
|
visiting,
|
||||||
|
keyIndex);
|
||||||
if (scopedHolder == null) {
|
if (scopedHolder == null) {
|
||||||
if (managedSet) {
|
if (managedSet) {
|
||||||
excludedManagedSets++;
|
excludedManagedSets++;
|
||||||
@@ -82,21 +97,29 @@ final class DatapackStructureStateFilter {
|
|||||||
filteredSets.add(scopedHolder);
|
filteredSets.add(scopedHolder);
|
||||||
}
|
}
|
||||||
return new Selection(
|
return new Selection(
|
||||||
scaleFrequencyOverrides(List.copyOf(filteredSets), importedStructures),
|
scaleFrequencyOverrides(
|
||||||
|
List.copyOf(filteredSets), importedStructures, keyIndex),
|
||||||
retainedManagedSets,
|
retainedManagedSets,
|
||||||
excludedManagedSets);
|
excludedManagedSets);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String structureSetKey(Holder<StructureSet> holder) {
|
static String structureSetKey(Holder<StructureSet> holder) {
|
||||||
Optional<ResourceKey<StructureSet>> key = holder.unwrapKey();
|
return structureSetKey(holder, keyIndex(List.of(holder)));
|
||||||
if (key.isPresent()) {
|
|
||||||
return key.get().identifier().toString();
|
|
||||||
}
|
}
|
||||||
if (holder.value().placement()
|
|
||||||
instanceof ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement) {
|
static StructureSetKeyIndex keyIndex(
|
||||||
return keyedPlacement.key.identifier().toString();
|
Iterable<? extends Holder<StructureSet>> registeredStructureSets
|
||||||
|
) {
|
||||||
|
return StructureSetKeyIndex.create(registeredStructureSets);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
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(
|
private static List<StructureSet.StructureSelectionEntry> filterEntries(
|
||||||
@@ -122,7 +145,8 @@ final class DatapackStructureStateFilter {
|
|||||||
Set<String> declaredSources,
|
Set<String> declaredSources,
|
||||||
Map<String, Holder<StructureSet>> holdersByKey,
|
Map<String, Holder<StructureSet>> holdersByKey,
|
||||||
Map<Holder<StructureSet>, Holder<StructureSet>> scopedByIdentity,
|
Map<Holder<StructureSet>, Holder<StructureSet>> scopedByIdentity,
|
||||||
Set<Holder<StructureSet>> visiting
|
Set<Holder<StructureSet>> visiting,
|
||||||
|
StructureSetKeyIndex keyIndex
|
||||||
) {
|
) {
|
||||||
if (scopedByIdentity.containsKey(holder)) {
|
if (scopedByIdentity.containsKey(holder)) {
|
||||||
return scopedByIdentity.get(holder);
|
return scopedByIdentity.get(holder);
|
||||||
@@ -131,7 +155,7 @@ final class DatapackStructureStateFilter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
String setKey = structureSetKey(holder);
|
String setKey = structureSetKey(holder, keyIndex);
|
||||||
if (setKey != null && scopeIndex.isManagedStructureSet(setKey)
|
if (setKey != null && scopeIndex.isManagedStructureSet(setKey)
|
||||||
&& !scopeIndex.allowsStructureSet(setKey, declaredSources)) {
|
&& !scopeIndex.allowsStructureSet(setKey, declaredSources)) {
|
||||||
scopedByIdentity.put(holder, null);
|
scopedByIdentity.put(holder, null);
|
||||||
@@ -150,14 +174,15 @@ final class DatapackStructureStateFilter {
|
|||||||
declaredSources,
|
declaredSources,
|
||||||
holdersByKey,
|
holdersByKey,
|
||||||
scopedByIdentity,
|
scopedByIdentity,
|
||||||
visiting);
|
visiting,
|
||||||
|
keyIndex);
|
||||||
if (entries.size() == originalSet.structures().size()
|
if (entries.size() == originalSet.structures().size()
|
||||||
&& placement == originalSet.placement()) {
|
&& placement == originalSet.placement()) {
|
||||||
scopedByIdentity.put(holder, holder);
|
scopedByIdentity.put(holder, holder);
|
||||||
return holder;
|
return holder;
|
||||||
}
|
}
|
||||||
Holder<StructureSet> scoped = replacementHolder(
|
Holder<StructureSet> scoped = replacementHolder(
|
||||||
holder, new StructureSet(entries, placement));
|
holder, new StructureSet(entries, placement), keyIndex);
|
||||||
scopedByIdentity.put(holder, scoped);
|
scopedByIdentity.put(holder, scoped);
|
||||||
return scoped;
|
return scoped;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -184,7 +209,8 @@ final class DatapackStructureStateFilter {
|
|||||||
Set<String> declaredSources,
|
Set<String> declaredSources,
|
||||||
Map<String, Holder<StructureSet>> holdersByKey,
|
Map<String, Holder<StructureSet>> holdersByKey,
|
||||||
Map<Holder<StructureSet>, Holder<StructureSet>> scopedByIdentity,
|
Map<Holder<StructureSet>, Holder<StructureSet>> scopedByIdentity,
|
||||||
Set<Holder<StructureSet>> visiting
|
Set<Holder<StructureSet>> visiting,
|
||||||
|
StructureSetKeyIndex keyIndex
|
||||||
) {
|
) {
|
||||||
Optional<StructurePlacement.ExclusionZone> currentZone = exclusionZone(placement);
|
Optional<StructurePlacement.ExclusionZone> currentZone = exclusionZone(placement);
|
||||||
if (currentZone.isEmpty()) {
|
if (currentZone.isEmpty()) {
|
||||||
@@ -192,7 +218,7 @@ final class DatapackStructureStateFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Holder<StructureSet> target = currentZone.get().otherSet();
|
Holder<StructureSet> target = currentZone.get().otherSet();
|
||||||
String targetKey = structureSetKey(target);
|
String targetKey = structureSetKey(target, keyIndex);
|
||||||
if (targetKey != null) {
|
if (targetKey != null) {
|
||||||
target = holdersByKey.getOrDefault(targetKey, target);
|
target = holdersByKey.getOrDefault(targetKey, target);
|
||||||
}
|
}
|
||||||
@@ -202,7 +228,8 @@ final class DatapackStructureStateFilter {
|
|||||||
declaredSources,
|
declaredSources,
|
||||||
holdersByKey,
|
holdersByKey,
|
||||||
scopedByIdentity,
|
scopedByIdentity,
|
||||||
visiting);
|
visiting,
|
||||||
|
keyIndex);
|
||||||
Optional<StructurePlacement.ExclusionZone> scopedZone = Optional.empty();
|
Optional<StructurePlacement.ExclusionZone> scopedZone = Optional.empty();
|
||||||
if (scopedTarget != null) {
|
if (scopedTarget != null) {
|
||||||
scopedZone = Optional.of(new StructurePlacement.ExclusionZone(
|
scopedZone = Optional.of(new StructurePlacement.ExclusionZone(
|
||||||
@@ -217,14 +244,15 @@ final class DatapackStructureStateFilter {
|
|||||||
|
|
||||||
private static List<Holder<StructureSet>> scaleFrequencyOverrides(
|
private static List<Holder<StructureSet>> scaleFrequencyOverrides(
|
||||||
List<Holder<StructureSet>> structureSets,
|
List<Holder<StructureSet>> structureSets,
|
||||||
IrisImportedStructureControl importedStructures
|
IrisImportedStructureControl importedStructures,
|
||||||
|
StructureSetKeyIndex keyIndex
|
||||||
) {
|
) {
|
||||||
if (!importedStructures.hasFrequencyOverrides()) {
|
if (!importedStructures.hasFrequencyOverrides()) {
|
||||||
return structureSets;
|
return structureSets;
|
||||||
}
|
}
|
||||||
Map<String, Holder<StructureSet>> holdersByKey = new HashMap<>();
|
Map<String, Holder<StructureSet>> holdersByKey = new HashMap<>();
|
||||||
for (Holder<StructureSet> holder : structureSets) {
|
for (Holder<StructureSet> holder : structureSets) {
|
||||||
String key = structureSetKey(holder);
|
String key = structureSetKey(holder, keyIndex);
|
||||||
if (key != null) {
|
if (key != null) {
|
||||||
holdersByKey.putIfAbsent(key, holder);
|
holdersByKey.putIfAbsent(key, holder);
|
||||||
}
|
}
|
||||||
@@ -234,7 +262,11 @@ final class DatapackStructureStateFilter {
|
|||||||
boolean changed = false;
|
boolean changed = false;
|
||||||
for (Holder<StructureSet> holder : structureSets) {
|
for (Holder<StructureSet> holder : structureSets) {
|
||||||
Holder<StructureSet> scaled = scaleFrequencyHolder(
|
Holder<StructureSet> scaled = scaleFrequencyHolder(
|
||||||
holder, importedStructures, holdersByKey, scaledByIdentity);
|
holder,
|
||||||
|
importedStructures,
|
||||||
|
holdersByKey,
|
||||||
|
scaledByIdentity,
|
||||||
|
keyIndex);
|
||||||
scaledSets.add(scaled);
|
scaledSets.add(scaled);
|
||||||
changed |= scaled != holder;
|
changed |= scaled != holder;
|
||||||
}
|
}
|
||||||
@@ -245,17 +277,19 @@ final class DatapackStructureStateFilter {
|
|||||||
Holder<StructureSet> holder,
|
Holder<StructureSet> holder,
|
||||||
IrisImportedStructureControl importedStructures,
|
IrisImportedStructureControl importedStructures,
|
||||||
Map<String, Holder<StructureSet>> holdersByKey,
|
Map<String, Holder<StructureSet>> holdersByKey,
|
||||||
Map<Holder<StructureSet>, Holder<StructureSet>> scaledByIdentity
|
Map<Holder<StructureSet>, Holder<StructureSet>> scaledByIdentity,
|
||||||
|
StructureSetKeyIndex keyIndex
|
||||||
) {
|
) {
|
||||||
if (scaledByIdentity.containsKey(holder)) {
|
if (scaledByIdentity.containsKey(holder)) {
|
||||||
return scaledByIdentity.get(holder);
|
return scaledByIdentity.get(holder);
|
||||||
}
|
}
|
||||||
if (!dependsOnFrequencyOverride(holder, importedStructures, holdersByKey)) {
|
if (!dependsOnFrequencyOverride(
|
||||||
|
holder, importedStructures, holdersByKey, keyIndex)) {
|
||||||
scaledByIdentity.put(holder, holder);
|
scaledByIdentity.put(holder, holder);
|
||||||
return holder;
|
return holder;
|
||||||
}
|
}
|
||||||
|
|
||||||
ResourceKey<StructureSet> holderKey = structureSetResourceKey(holder).orElseThrow(() ->
|
ResourceKey<StructureSet> holderKey = keyIndex.resolve(holder).orElseThrow(() ->
|
||||||
new IllegalStateException(
|
new IllegalStateException(
|
||||||
"An affected native structure-set exclusion graph has an unkeyed holder"));
|
"An affected native structure-set exclusion graph has an unkeyed holder"));
|
||||||
ReboundStructureSetHolder scaledHolder = new ReboundStructureSetHolder(holderKey);
|
ReboundStructureSetHolder scaledHolder = new ReboundStructureSetHolder(holderKey);
|
||||||
@@ -267,16 +301,21 @@ final class DatapackStructureStateFilter {
|
|||||||
Optional<StructurePlacement.ExclusionZone> scaledZone = originalZone;
|
Optional<StructurePlacement.ExclusionZone> scaledZone = originalZone;
|
||||||
if (originalZone.isPresent()) {
|
if (originalZone.isPresent()) {
|
||||||
Holder<StructureSet> target = canonicalHolder(
|
Holder<StructureSet> target = canonicalHolder(
|
||||||
originalZone.get().otherSet(), holdersByKey);
|
originalZone.get().otherSet(), holdersByKey, keyIndex);
|
||||||
Holder<StructureSet> scaledTarget = scaleFrequencyHolder(
|
Holder<StructureSet> scaledTarget = scaleFrequencyHolder(
|
||||||
target, importedStructures, holdersByKey, scaledByIdentity);
|
target,
|
||||||
|
importedStructures,
|
||||||
|
holdersByKey,
|
||||||
|
scaledByIdentity,
|
||||||
|
keyIndex);
|
||||||
if (scaledTarget != originalZone.get().otherSet()) {
|
if (scaledTarget != originalZone.get().otherSet()) {
|
||||||
scaledZone = Optional.of(new StructurePlacement.ExclusionZone(
|
scaledZone = Optional.of(new StructurePlacement.ExclusionZone(
|
||||||
scaledTarget, originalZone.get().chunkCount()));
|
scaledTarget, originalZone.get().chunkCount()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
double multiplier = importedStructures.frequencyMultiplier(structureSetKey(holder));
|
double multiplier = importedStructures.frequencyMultiplier(
|
||||||
|
structureSetKey(holder, keyIndex));
|
||||||
StructurePlacement scaledPlacement = multiplier == 1D && scaledZone.equals(originalZone)
|
StructurePlacement scaledPlacement = multiplier == 1D && scaledZone.equals(originalZone)
|
||||||
? originalPlacement
|
? originalPlacement
|
||||||
: copyPlacement(originalPlacement, scaledZone, multiplier);
|
: copyPlacement(originalPlacement, scaledZone, multiplier);
|
||||||
@@ -287,12 +326,14 @@ final class DatapackStructureStateFilter {
|
|||||||
private static boolean dependsOnFrequencyOverride(
|
private static boolean dependsOnFrequencyOverride(
|
||||||
Holder<StructureSet> holder,
|
Holder<StructureSet> holder,
|
||||||
IrisImportedStructureControl importedStructures,
|
IrisImportedStructureControl importedStructures,
|
||||||
Map<String, Holder<StructureSet>> holdersByKey
|
Map<String, Holder<StructureSet>> holdersByKey,
|
||||||
|
StructureSetKeyIndex keyIndex
|
||||||
) {
|
) {
|
||||||
Set<Holder<StructureSet>> visited = Collections.newSetFromMap(new IdentityHashMap<>());
|
Set<Holder<StructureSet>> visited = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||||
Holder<StructureSet> current = holder;
|
Holder<StructureSet> current = holder;
|
||||||
while (visited.add(current)) {
|
while (visited.add(current)) {
|
||||||
if (importedStructures.frequencyMultiplier(structureSetKey(current)) != 1D) {
|
if (importedStructures.frequencyMultiplier(
|
||||||
|
structureSetKey(current, keyIndex)) != 1D) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
Optional<StructurePlacement.ExclusionZone> zone =
|
Optional<StructurePlacement.ExclusionZone> zone =
|
||||||
@@ -300,24 +341,26 @@ final class DatapackStructureStateFilter {
|
|||||||
if (zone.isEmpty()) {
|
if (zone.isEmpty()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
current = canonicalHolder(zone.get().otherSet(), holdersByKey);
|
current = canonicalHolder(zone.get().otherSet(), holdersByKey, keyIndex);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Holder<StructureSet> canonicalHolder(
|
private static Holder<StructureSet> canonicalHolder(
|
||||||
Holder<StructureSet> holder,
|
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);
|
return key == null ? holder : holdersByKey.getOrDefault(key, holder);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Holder<StructureSet> replacementHolder(
|
private static Holder<StructureSet> replacementHolder(
|
||||||
Holder<StructureSet> original,
|
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()) {
|
if (key.isEmpty()) {
|
||||||
return Holder.direct(replacement);
|
return Holder.direct(replacement);
|
||||||
}
|
}
|
||||||
@@ -326,20 +369,6 @@ final class DatapackStructureStateFilter {
|
|||||||
return holder;
|
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(
|
private static StructurePlacement copyPlacement(
|
||||||
StructurePlacement placement,
|
StructurePlacement placement,
|
||||||
Optional<StructurePlacement.ExclusionZone> exclusionZone,
|
Optional<StructurePlacement.ExclusionZone> exclusionZone,
|
||||||
@@ -357,26 +386,7 @@ final class DatapackStructureStateFilter {
|
|||||||
int salt = (int) declaredFieldValue(
|
int salt = (int) declaredFieldValue(
|
||||||
StructurePlacement.class, placement, int.class);
|
StructurePlacement.class, placement, int.class);
|
||||||
|
|
||||||
if (placement.getClass()
|
if (placement instanceof RandomSpreadStructurePlacement randomSpread) {
|
||||||
== 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;
|
|
||||||
NativeStructureFrequencyScale scale = NativeStructureFrequencyScale.randomSpread(
|
NativeStructureFrequencyScale scale = NativeStructureFrequencyScale.randomSpread(
|
||||||
frequency, randomSpread.spacing(), randomSpread.separation(), frequencyMultiplier);
|
frequency, randomSpread.spacing(), randomSpread.separation(), frequencyMultiplier);
|
||||||
return new RandomSpreadStructurePlacement(
|
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(
|
record Selection(
|
||||||
List<Holder<StructureSet>> structureSets,
|
List<Holder<StructureSet>> structureSets,
|
||||||
int retainedManagedSets,
|
int retainedManagedSets,
|
||||||
|
|||||||
+140
-126
@@ -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.Pair;
|
||||||
import art.arcane.iris.core.nms.container.BlockProperty;
|
import art.arcane.iris.core.nms.container.BlockProperty;
|
||||||
import art.arcane.iris.core.nms.datapack.DataVersion;
|
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.cache.AtomicCache;
|
||||||
import art.arcane.iris.engine.data.chunk.TerrainChunk;
|
import art.arcane.iris.engine.data.chunk.TerrainChunk;
|
||||||
import art.arcane.iris.engine.framework.Engine;
|
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 art.arcane.iris.util.common.scheduling.J;
|
||||||
import it.unimi.dsi.fastutil.objects.Object2IntMap;
|
import it.unimi.dsi.fastutil.objects.Object2IntMap;
|
||||||
import it.unimi.dsi.fastutil.shorts.ShortList;
|
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.ByteBuddy;
|
||||||
import net.bytebuddy.agent.builder.AgentBuilder;
|
import net.bytebuddy.agent.builder.AgentBuilder;
|
||||||
import net.bytebuddy.agent.builder.ResettableClassFileTransformer;
|
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.StructureCheck;
|
||||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||||
|
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||||
import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure;
|
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.AbstractHugeMushroomFeature;
|
||||||
import net.minecraft.world.level.levelgen.feature.ConfiguredFeature;
|
import net.minecraft.world.level.levelgen.feature.ConfiguredFeature;
|
||||||
import net.minecraft.world.level.levelgen.feature.FallenTreeFeature;
|
import net.minecraft.world.level.levelgen.feature.FallenTreeFeature;
|
||||||
import net.minecraft.world.level.levelgen.feature.Feature;
|
import net.minecraft.world.level.levelgen.feature.Feature;
|
||||||
import net.minecraft.world.level.levelgen.feature.TreeFeature;
|
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.FlatLayerInfo;
|
||||||
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
|
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
@@ -162,8 +159,6 @@ import java.lang.reflect.Field;
|
|||||||
import java.lang.reflect.InvocationTargetException;
|
import java.lang.reflect.InvocationTargetException;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.lang.reflect.Modifier;
|
import java.lang.reflect.Modifier;
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.LinkOption;
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -173,23 +168,18 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
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.Executor;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.TimeoutException;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
public class NMSBinding implements INMSBinding {
|
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 final KMap<Biome, Object> baseBiomeCache = new KMap<>();
|
||||||
private volatile DataVersion dataVersion;
|
private volatile DataVersion dataVersion;
|
||||||
private final BlockData AIR = Material.AIR.createBlockData();
|
private final BlockData AIR = Material.AIR.createBlockData();
|
||||||
private final AtomicCache<MCAIdMap<net.minecraft.world.level.biome.Biome>> biomeMapCache = new AtomicCache<>();
|
private final AtomicCache<MCAIdMap<net.minecraft.world.level.biome.Biome>> biomeMapCache = new AtomicCache<>();
|
||||||
private final AtomicBoolean injected = new AtomicBoolean();
|
private final AtomicBoolean injected = new AtomicBoolean();
|
||||||
|
private volatile ResettableClassFileTransformer levelStorageAccessTransformer;
|
||||||
private volatile ResettableClassFileTransformer serverLevelTransformer;
|
private volatile ResettableClassFileTransformer serverLevelTransformer;
|
||||||
private final AtomicCache<MCAIdMapper<BlockState>> registryCache = new AtomicCache<>();
|
private final AtomicCache<MCAIdMapper<BlockState>> registryCache = new AtomicCache<>();
|
||||||
private final AtomicCache<MCAPalette<BlockState>> globalCache = new AtomicCache<>();
|
private final AtomicCache<MCAPalette<BlockState>> globalCache = new AtomicCache<>();
|
||||||
@@ -1249,8 +1239,15 @@ public class NMSBinding implements INMSBinding {
|
|||||||
ChunkGeneratorStructureState currentState = level.getChunkSource().getGeneratorState();
|
ChunkGeneratorStructureState currentState = level.getChunkSource().getGeneratorState();
|
||||||
net.minecraft.world.level.chunk.ChunkGenerator generator = level.getChunkSource().getGenerator();
|
net.minecraft.world.level.chunk.ChunkGenerator generator = level.getChunkSource().getGenerator();
|
||||||
ChunkGeneratorStructureState scopedState = createStructureState(level, generator, currentState);
|
ChunkGeneratorStructureState scopedState = createStructureState(level, generator, currentState);
|
||||||
|
Registry<StructureSet> structureSetRegistry =
|
||||||
|
level.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET);
|
||||||
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
|
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);
|
Field possibleSetsField = getField(scopedState.getClass(), List.class);
|
||||||
possibleSetsField.setAccessible(true);
|
possibleSetsField.setAccessible(true);
|
||||||
@@ -1328,7 +1325,7 @@ public class NMSBinding implements INMSBinding {
|
|||||||
level.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET),
|
level.registryAccess().lookupOrThrow(Registries.STRUCTURE_SET),
|
||||||
currentState.randomState(),
|
currentState.randomState(),
|
||||||
currentState.getLevelSeed(),
|
currentState.getLevelSeed(),
|
||||||
currentState.conf);
|
level.spigotConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeAndPublishStructureState(
|
private void initializeAndPublishStructureState(
|
||||||
@@ -1542,6 +1539,18 @@ public class NMSBinding implements INMSBinding {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
IrisLogging.info("Injecting Bukkit");
|
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()
|
serverLevelTransformer = new AgentBuilder.Default()
|
||||||
.disableClassFormatChanges()
|
.disableClassFormatChanges()
|
||||||
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
|
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
|
||||||
@@ -1549,8 +1558,7 @@ public class NMSBinding implements INMSBinding {
|
|||||||
.transform((builder, typeDescription, classLoader, module, protectionDomain) ->
|
.transform((builder, typeDescription, classLoader, module, protectionDomain) ->
|
||||||
builder.visit(Advice.to(ServerLevelAdvice.class).on(ElementMatchers.isConstructor()
|
builder.visit(Advice.to(ServerLevelAdvice.class).on(ElementMatchers.isConstructor()
|
||||||
.and(ElementMatchers.takesArgument(0, MinecraftServer.class))
|
.and(ElementMatchers.takesArgument(0, MinecraftServer.class))
|
||||||
.and(ElementMatchers.takesArgument(5, LevelStem.class))
|
.and(ElementMatchers.takesArgument(5, LevelStem.class)))))
|
||||||
.and(ElementMatchers.takesArgument(12, ChunkGenerator.class)))))
|
|
||||||
.installOn(Agent.getInstrumentation());
|
.installOn(Agent.getInstrumentation());
|
||||||
ByteBuddy buddy = new ByteBuddy();
|
ByteBuddy buddy = new ByteBuddy();
|
||||||
for (Class<?> clazz : List.of(ChunkAccess.class, ProtoChunk.class)) {
|
for (Class<?> clazz : List.of(ChunkAccess.class, ProtoChunk.class)) {
|
||||||
@@ -1565,12 +1573,17 @@ public class NMSBinding implements INMSBinding {
|
|||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
IrisLogging.error(C.RED + "Failed to inject Bukkit");
|
IrisLogging.error(C.RED + "Failed to inject Bukkit");
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
// The ServerLevel transformer may already be installed when the ChunkAccess
|
ResettableClassFileTransformer partialServerLevel = serverLevelTransformer;
|
||||||
// redefine throws; remove it or a retry would stack a second one and orphan
|
ResettableClassFileTransformer partialStorageAccess = levelStorageAccessTransformer;
|
||||||
// this one permanently.
|
|
||||||
ResettableClassFileTransformer partial = serverLevelTransformer;
|
|
||||||
serverLevelTransformer = null;
|
serverLevelTransformer = null;
|
||||||
if (partial != null) {
|
levelStorageAccessTransformer = null;
|
||||||
|
for (ResettableClassFileTransformer partial : new ResettableClassFileTransformer[]{
|
||||||
|
partialServerLevel,
|
||||||
|
partialStorageAccess
|
||||||
|
}) {
|
||||||
|
if (partial == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
partial.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION);
|
partial.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION);
|
||||||
} catch (Throwable ignored) {
|
} catch (Throwable ignored) {
|
||||||
@@ -1586,29 +1599,39 @@ public class NMSBinding implements INMSBinding {
|
|||||||
if (!injected.get())
|
if (!injected.get())
|
||||||
return;
|
return;
|
||||||
try {
|
try {
|
||||||
Agent.getInstrumentation().retransformClasses(ServerLevel.class);
|
Agent.getInstrumentation().retransformClasses(
|
||||||
|
LevelStorageSource.LevelStorageAccess.class,
|
||||||
|
ServerLevel.class
|
||||||
|
);
|
||||||
} catch (Throwable e) {
|
} 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
|
@Override
|
||||||
public void uninjectBukkit() {
|
public void uninjectBukkit() {
|
||||||
synchronized (injected) {
|
synchronized (injected) {
|
||||||
ResettableClassFileTransformer transformer = serverLevelTransformer;
|
ResettableClassFileTransformer activeServerLevel = serverLevelTransformer;
|
||||||
|
ResettableClassFileTransformer activeStorageAccess = levelStorageAccessTransformer;
|
||||||
serverLevelTransformer = null;
|
serverLevelTransformer = null;
|
||||||
|
levelStorageAccessTransformer = null;
|
||||||
injected.set(false);
|
injected.set(false);
|
||||||
|
for (ResettableClassFileTransformer transformer : new ResettableClassFileTransformer[]{
|
||||||
|
activeServerLevel,
|
||||||
|
activeStorageAccess
|
||||||
|
}) {
|
||||||
if (transformer == null) {
|
if (transformer == null) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
transformer.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION);
|
transformer.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION);
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
IrisLogging.error(C.RED + "Failed to remove ServerLevel injection");
|
IrisLogging.error(C.RED + "Failed to remove Bukkit world lifecycle injection");
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void writeCurrentPaperWorldData(
|
public void writeCurrentPaperWorldData(
|
||||||
@@ -1616,103 +1639,7 @@ public class NMSBinding implements INMSBinding {
|
|||||||
Path targetWorldDirectory,
|
Path targetWorldDirectory,
|
||||||
long seed
|
long seed
|
||||||
) throws IOException {
|
) throws IOException {
|
||||||
CraftServer craftServer = (CraftServer) Bukkit.getServer();
|
CurrentPaperWorldDataWriter.write(sourceWorldDirectory, targetWorldDirectory, seed);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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 {
|
private static class ServerLevelAdvice {
|
||||||
@Advice.OnMethodEnter
|
@Advice.OnMethodEnter
|
||||||
static void enter(
|
static void enter(
|
||||||
@Advice.Argument(0) MinecraftServer server,
|
@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(value = 5, readOnly = false) LevelStem levelStem,
|
||||||
@Advice.Argument(12) ChunkGenerator constructorGenerator
|
@Advice.AllArguments Object[] constructorArguments
|
||||||
) {
|
) {
|
||||||
if (dimensionKey == null)
|
if (dimensionKey == null)
|
||||||
return;
|
return;
|
||||||
@@ -1818,6 +1808,13 @@ public class NMSBinding implements INMSBinding {
|
|||||||
// the vanilla stem corrupts generation.
|
// the vanilla stem corrupts generation.
|
||||||
ChunkGenerator gen = null;
|
ChunkGenerator gen = null;
|
||||||
try {
|
try {
|
||||||
|
ChunkGenerator constructorGenerator = null;
|
||||||
|
for (Object argument : constructorArguments) {
|
||||||
|
if (argument instanceof ChunkGenerator candidate) {
|
||||||
|
constructorGenerator = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
Object generator = generatorType.isInstance(constructorGenerator) ? constructorGenerator : null;
|
Object generator = generatorType.isInstance(constructorGenerator) ? constructorGenerator : null;
|
||||||
if (generator == null) {
|
if (generator == null) {
|
||||||
generator = stagingType
|
generator = stagingType
|
||||||
@@ -1855,10 +1852,27 @@ public class NMSBinding implements INMSBinding {
|
|||||||
if (stemMethod == null) {
|
if (stemMethod == null) {
|
||||||
throw new IllegalStateException("Iris binding is missing createRuntimeLevelStem.");
|
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);
|
Object resolvedStem = stemMethod.invoke(bindings, server.registryAccess(), gen);
|
||||||
if (!(resolvedStem instanceof LevelStem runtimeStem)) {
|
if (!(resolvedStem instanceof LevelStem runtimeStem)) {
|
||||||
throw new IllegalStateException("Iris runtime LevelStem binding returned " + (resolvedStem == null ? "null" : resolvedStem.getClass().getName()) + ".");
|
throw new IllegalStateException("Iris runtime LevelStem binding returned " + (resolvedStem == null ? "null" : resolvedStem.getClass().getName()) + ".");
|
||||||
}
|
}
|
||||||
|
dimensionKey = ResourceKey.create(Registries.DIMENSION, worldIdentifier);
|
||||||
levelStem = runtimeStem;
|
levelStem = runtimeStem;
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
throw new RuntimeException("Iris failed to replace the levelStem", e instanceof InvocationTargetException ex ? ex.getCause() : e);
|
throw new RuntimeException("Iris failed to replace the levelStem", e instanceof InvocationTargetException ex ? ex.getCause() : e);
|
||||||
|
|||||||
+8
@@ -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
|
@Override
|
||||||
public ChunkAccess getChunk(int chunkX, int chunkZ, ChunkStatus status, boolean create) {
|
public ChunkAccess getChunk(int chunkX, int chunkZ, ChunkStatus status, boolean create) {
|
||||||
if (isInsideGenerationRegion(chunkX, chunkZ)) {
|
if (isInsideGenerationRegion(chunkX, chunkZ)) {
|
||||||
|
|||||||
+45
-12
@@ -3,29 +3,54 @@ package art.arcane.iris.core.nms.v26_2_R1;
|
|||||||
import art.arcane.iris.core.nms.INMSBinding;
|
import art.arcane.iris.core.nms.INMSBinding;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
import java.lang.reflect.InvocationHandler;
|
import java.lang.reflect.InvocationHandler;
|
||||||
import java.lang.reflect.Proxy;
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
import static org.junit.Assert.assertFalse;
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
import static org.junit.Assert.assertThrows;
|
import static org.junit.Assert.assertThrows;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public class NMSBindingCurrentPaperWorldDataContractTest {
|
public class NMSBindingCurrentPaperWorldDataContractTest {
|
||||||
@Test
|
@Test
|
||||||
public void stagesAllCurrentPaperWorldDataFromLiveServerState() throws Exception {
|
public void bindingDelegatesWithoutLinkingPaperSavedDataClasses() throws Exception {
|
||||||
String source = Files.readString(Path.of(System.getProperty("iris.nmsBindingSource")));
|
String bindingSource = Files.readString(bindingSourcePath());
|
||||||
String writer = section(
|
String writer = section(
|
||||||
source,
|
bindingSource,
|
||||||
"public void writeCurrentPaperWorldData(",
|
"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("WorldReplacementSeed.copyWithAuthoritativeSeed("));
|
||||||
assertTrue(writer.contains("UUID metadataUuid = UUID.randomUUID()"));
|
assertTrue(writer.contains("UUID metadataUuid = UUID.randomUUID()"));
|
||||||
assertTrue(writer.contains("new PaperWorldMetadata(metadataUuid)"));
|
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("new SavedDataStorage("));
|
||||||
assertTrue(writer.contains("server.getFixerUpper()"));
|
assertTrue(writer.contains("server.getFixerUpper()"));
|
||||||
assertTrue(writer.contains("server.registryAccess()"));
|
assertTrue(writer.contains("server.registryAccess()"));
|
||||||
@@ -44,23 +69,23 @@ public class NMSBindingCurrentPaperWorldDataContractTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void capturesOnlyLiveLevelOverridesOnTheGlobalThread() throws Exception {
|
public void capturesOnlyLiveLevelOverridesOnTheGlobalThread() throws Exception {
|
||||||
String source = Files.readString(Path.of(System.getProperty("iris.nmsBindingSource")));
|
String source = Files.readString(writerSourcePath());
|
||||||
String capture = section(
|
String capture = section(
|
||||||
source,
|
source,
|
||||||
"private PaperLevelOverrides captureCurrentPaperLevelOverrides(",
|
"private static PaperLevelOverrides captureLevelOverrides(",
|
||||||
"private PaperLevelOverrides createCurrentPaperLevelOverrides("
|
"private static PaperLevelOverrides createLevelOverrides("
|
||||||
);
|
);
|
||||||
String create = section(
|
String create = section(
|
||||||
source,
|
source,
|
||||||
"private PaperLevelOverrides createCurrentPaperLevelOverrides(",
|
"private static PaperLevelOverrides createLevelOverrides(",
|
||||||
"public KMap<Material, List<BlockProperty>> getBlockProperties()"
|
"\n }\n}"
|
||||||
);
|
);
|
||||||
|
|
||||||
assertTrue(capture.contains("craftServer.isGlobalTickThread()"));
|
assertTrue(capture.contains("craftServer.isGlobalTickThread()"));
|
||||||
assertTrue(capture.contains("J.isFolia() && J.isPrimaryThread()"));
|
assertTrue(capture.contains("J.isFolia() && J.isPrimaryThread()"));
|
||||||
assertTrue(capture.contains("J.runGlobal("));
|
assertTrue(capture.contains("J.runGlobal("));
|
||||||
assertTrue(capture.contains("createCurrentPaperLevelOverrides(craftServer, server)"));
|
assertTrue(capture.contains("createLevelOverrides(craftServer, server)"));
|
||||||
assertTrue(capture.contains("captured.get(CURRENT_WORLD_DATA_SNAPSHOT_TIMEOUT_SECONDS"));
|
assertTrue(capture.contains("captured.get(SNAPSHOT_TIMEOUT_SECONDS"));
|
||||||
assertTrue(capture.contains("Thread.currentThread().interrupt()"));
|
assertTrue(capture.contains("Thread.currentThread().interrupt()"));
|
||||||
assertTrue(create.contains("if (!craftServer.isGlobalTickThread())"));
|
assertTrue(create.contains("if (!craftServer.isGlobalTickThread())"));
|
||||||
assertTrue(create.indexOf("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"));
|
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) {
|
private static String section(String source, String startMarker, String endMarker) {
|
||||||
int start = source.indexOf(startMarker);
|
int start = source.indexOf(startMarker);
|
||||||
int end = source.indexOf(endMarker, start);
|
int end = source.indexOf(endMarker, start);
|
||||||
|
|||||||
+72
-23
@@ -27,6 +27,8 @@ import org.junit.Test;
|
|||||||
import org.junit.BeforeClass;
|
import org.junit.BeforeClass;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -38,8 +40,8 @@ import java.util.stream.Stream;
|
|||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertFalse;
|
import static org.junit.Assert.assertFalse;
|
||||||
import static org.junit.Assert.assertNotSame;
|
import static org.junit.Assert.assertNotSame;
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
import static org.junit.Assert.assertSame;
|
import static org.junit.Assert.assertSame;
|
||||||
import static org.junit.Assert.assertThrows;
|
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public class NMSBindingDatapackStructureScopeTest {
|
public class NMSBindingDatapackStructureScopeTest {
|
||||||
@@ -108,28 +110,56 @@ public class NMSBindingDatapackStructureScopeTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void spigotDirectHolderRetainsItsStructureSetKey() {
|
public void spigotDirectHolderRecoversItsRegistryKeyFromSharedEntries() {
|
||||||
ResourceKey<StructureSet> key = ResourceKey.create(
|
ResourceKey<StructureSet> key = ResourceKey.create(
|
||||||
Registries.STRUCTURE_SET,
|
Registries.STRUCTURE_SET,
|
||||||
Identifier.parse("minecraft:villages"));
|
Identifier.parse("managed:illager_barracks"));
|
||||||
ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement placement =
|
List<StructureSet.StructureSelectionEntry> entries = List.of(
|
||||||
new ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement(
|
new StructureSet.StructureSelectionEntry(
|
||||||
key,
|
structureHolder("minecraft:pillager_outpost"), 1));
|
||||||
Vec3i.ZERO,
|
Holder<StructureSet> registered = new KeyedHolder<>(key, new StructureSet(
|
||||||
StructurePlacement.FrequencyReductionMethod.DEFAULT,
|
entries,
|
||||||
1.0F,
|
new RandomSpreadStructurePlacement(
|
||||||
10387312,
|
34, 8, RandomSpreadType.LINEAR, 10387312)));
|
||||||
Optional.empty(),
|
Holder<StructureSet> spigotDirect = Holder.direct(new StructureSet(
|
||||||
34,
|
entries,
|
||||||
8,
|
new RandomSpreadStructurePlacement(
|
||||||
RandomSpreadType.LINEAR);
|
34, 8, RandomSpreadType.LINEAR, 14357620)));
|
||||||
StructureSet structureSet = new StructureSet(
|
DatapackStructureScopeIndex scopeIndex = index(
|
||||||
List.of(new StructureSet.StructureSelectionEntry(
|
List.of(), List.of("managed:illager_barracks"));
|
||||||
structureHolder("minecraft:village_plains"), 1)),
|
DatapackStructureStateFilter.StructureSetKeyIndex keyIndex =
|
||||||
placement);
|
DatapackStructureStateFilter.keyIndex(List.of(registered));
|
||||||
|
|
||||||
assertEquals("minecraft:villages",
|
DatapackStructureStateFilter.Selection excluded =
|
||||||
DatapackStructureStateFilter.structureSetKey(Holder.direct(structureSet)));
|
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
|
@Test
|
||||||
@@ -271,7 +301,7 @@ public class NMSBindingDatapackStructureScopeTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void affectedRandomSpreadSubclassFailsInsteadOfLosingSubtypeBehavior() {
|
public void randomSpreadSubtypeUsesTheCanonicalPlacementContract() {
|
||||||
Holder<StructureSet> custom = structureSetHolder(
|
Holder<StructureSet> custom = structureSetHolder(
|
||||||
"example:custom",
|
"example:custom",
|
||||||
new CustomRandomSpreadPlacement(),
|
new CustomRandomSpreadPlacement(),
|
||||||
@@ -283,8 +313,12 @@ public class NMSBindingDatapackStructureScopeTest {
|
|||||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||||
.setFrequencyOverrides(overrides);
|
.setFrequencyOverrides(overrides);
|
||||||
|
|
||||||
assertThrows(IllegalStateException.class, () -> DatapackStructureStateFilter.filter(
|
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
|
||||||
List.of(custom), index(List.of(), List.of()), Set.of(), control));
|
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
|
@Test
|
||||||
@@ -395,6 +429,21 @@ public class NMSBindingDatapackStructureScopeTest {
|
|||||||
assertTrue(structureRetarget > identityGate);
|
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(
|
private static DatapackStructureScopeIndex index(
|
||||||
List<String> structureKeys,
|
List<String> structureKeys,
|
||||||
List<String> structureSetKeys
|
List<String> structureSetKeys
|
||||||
|
|||||||
+103
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-1
@@ -135,6 +135,18 @@ public class NativeStructureWorldgenAccessTest {
|
|||||||
assertEquals(1, recording.blockTicks.count());
|
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
|
@Test
|
||||||
public void entityQueriesRejectDisjointAreasAndClampOverlaps() {
|
public void entityQueriesRejectDisjointAreasAndClampOverlaps() {
|
||||||
RecordingDelegate recording = new RecordingDelegate();
|
RecordingDelegate recording = new RecordingDelegate();
|
||||||
@@ -187,6 +199,8 @@ public class NativeStructureWorldgenAccessTest {
|
|||||||
private int terrainReads;
|
private int terrainReads;
|
||||||
private int heightReads;
|
private int heightReads;
|
||||||
private int chunkReads;
|
private int chunkReads;
|
||||||
|
private int statuslessChunkReads;
|
||||||
|
private int statusChunkReads;
|
||||||
private int mutations;
|
private int mutations;
|
||||||
private int events;
|
private int events;
|
||||||
|
|
||||||
@@ -240,7 +254,16 @@ public class NativeStructureWorldgenAccessTest {
|
|||||||
terrainReads++;
|
terrainReads++;
|
||||||
return Blocks.WATER.defaultBlockState().getFluidState();
|
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++;
|
chunkReads++;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ def mainClass = 'art.arcane.iris.Iris'
|
|||||||
def bootstrapperClass = 'art.arcane.iris.IrisBootstrap'
|
def bootstrapperClass = 'art.arcane.iris.IrisBootstrap'
|
||||||
|
|
||||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||||
.orElse('com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969')
|
.orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|||||||
@@ -756,6 +756,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
|||||||
IrisStartupValidation.begin();
|
IrisStartupValidation.begin();
|
||||||
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
|
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
|
||||||
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
|
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
|
||||||
|
pendingWorldReplacements.registerPlatformEntryListener();
|
||||||
boolean enabled = enable();
|
boolean enabled = enable();
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+29
-17
@@ -22,13 +22,13 @@ import art.arcane.iris.Iris;
|
|||||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
|
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
|
||||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||||
import art.arcane.iris.core.nms.INMS;
|
import art.arcane.iris.core.nms.INMS;
|
||||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
|
||||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||||
import art.arcane.iris.engine.object.IrisDimension;
|
import art.arcane.iris.engine.object.IrisDimension;
|
||||||
import art.arcane.iris.platform.bukkit.BukkitEnvironment;
|
import art.arcane.iris.platform.bukkit.BukkitEnvironment;
|
||||||
import art.arcane.iris.util.common.format.C;
|
import art.arcane.iris.util.common.format.C;
|
||||||
import art.arcane.iris.util.common.misc.ServerProperties;
|
import art.arcane.iris.util.common.misc.ServerProperties;
|
||||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.NamespacedKey;
|
import org.bukkit.NamespacedKey;
|
||||||
import org.bukkit.World;
|
import org.bukkit.World;
|
||||||
import org.bukkit.WorldCreator;
|
import org.bukkit.WorldCreator;
|
||||||
@@ -698,16 +698,17 @@ public final class BukkitWorldReconciler {
|
|||||||
@Override
|
@Override
|
||||||
public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
|
public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
|
||||||
try {
|
try {
|
||||||
String worldName = IrisWorldStorage.logicalName(worldKey);
|
String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
|
||||||
Iris.info("Loading World: %s | Generator: %s", worldName, dimension);
|
String configuredWorldName = configuredWorldName(worldKey);
|
||||||
ChunkGenerator generator = plugin.getDefaultWorldGenerator(worldName, dimension);
|
Iris.info("Loading World: %s | Generator: %s", logicalWorldName, dimension);
|
||||||
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension);
|
ChunkGenerator generator = plugin.getDefaultWorldGenerator(configuredWorldName, dimension);
|
||||||
|
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
|
||||||
if (generator == null || irisDimension == null) {
|
if (generator == null || irisDimension == null) {
|
||||||
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
|
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
|
||||||
}
|
}
|
||||||
|
|
||||||
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + worldName + " using Iris:" + dimension + "...");
|
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + logicalWorldName + " using Iris:" + dimension + "...");
|
||||||
WorldCreator creator = WorldCreatorCompat.ofKey(worldKey)
|
WorldCreator creator = WorldCreatorCompat.ofPersistentKey(worldKey)
|
||||||
.generator(generator)
|
.generator(generator)
|
||||||
.environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
|
.environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
|
||||||
if (seed != null) {
|
if (seed != null) {
|
||||||
@@ -726,7 +727,7 @@ public final class BukkitWorldReconciler {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public DimensionResolution resolveDimension(NamespacedKey worldKey) {
|
public DimensionResolution resolveDimension(NamespacedKey worldKey) {
|
||||||
File dimensionsDirectory = new File(IrisWorldStorage.packRoot(worldKey), "dimensions");
|
File dimensionsDirectory = new File(snapshotRoot(worldKey), "dimensions");
|
||||||
if (!dimensionsDirectory.isDirectory()) {
|
if (!dimensionsDirectory.isDirectory()) {
|
||||||
return DimensionResolution.failed(new IllegalStateException("The world has no Iris dimensions directory."));
|
return DimensionResolution.failed(new IllegalStateException("The world has no Iris dimensions directory."));
|
||||||
}
|
}
|
||||||
@@ -764,19 +765,30 @@ public final class BukkitWorldReconciler {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void requireDimensionLoadable(NamespacedKey worldKey, String dimension) {
|
public void requireDimensionLoadable(NamespacedKey worldKey, String dimension) {
|
||||||
File snapshotRoot = IrisWorldStorage.packRoot(worldKey);
|
File snapshotRoot = snapshotRoot(worldKey);
|
||||||
boolean snapshotPresent = snapshotRoot.isDirectory();
|
|
||||||
if (snapshotPresent) {
|
|
||||||
IrisWorldGeneratorResolver.requireSnapshotLoadable(snapshotRoot);
|
IrisWorldGeneratorResolver.requireSnapshotLoadable(snapshotRoot);
|
||||||
}
|
String configuredWorldName = configuredWorldName(worldKey);
|
||||||
String worldName = IrisWorldStorage.logicalName(worldKey);
|
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(configuredWorldName, dimension);
|
||||||
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension);
|
|
||||||
if (irisDimension == null) {
|
if (irisDimension == null) {
|
||||||
throw new IllegalStateException("Could not resolve the Iris dimension \"" + dimension + "\".");
|
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
-2
@@ -1,6 +1,5 @@
|
|||||||
package art.arcane.iris.core;
|
package art.arcane.iris.core;
|
||||||
|
|
||||||
import net.kyori.adventure.text.Component;
|
|
||||||
import org.bukkit.event.EventHandler;
|
import org.bukkit.event.EventHandler;
|
||||||
import org.bukkit.event.EventPriority;
|
import org.bukkit.event.EventPriority;
|
||||||
import org.bukkit.event.Listener;
|
import org.bukkit.event.Listener;
|
||||||
@@ -11,6 +10,6 @@ public final class IrisStartupAdmissionListener implements Listener {
|
|||||||
public void onAsyncPlayerPreLogin(AsyncPlayerPreLoginEvent event) {
|
public void onAsyncPlayerPreLogin(AsyncPlayerPreLoginEvent event) {
|
||||||
IrisStartupValidation.denialReason().ifPresent(reason -> event.disallow(
|
IrisStartupValidation.denialReason().ifPresent(reason -> event.disallow(
|
||||||
AsyncPlayerPreLoginEvent.Result.KICK_OTHER,
|
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."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-39
@@ -29,12 +29,12 @@ import art.arcane.iris.core.pack.PackValidationRegistry;
|
|||||||
import art.arcane.iris.core.pack.PackValidationResult;
|
import art.arcane.iris.core.pack.PackValidationResult;
|
||||||
import art.arcane.iris.core.pack.PackValidator;
|
import art.arcane.iris.core.pack.PackValidator;
|
||||||
import art.arcane.iris.spi.IrisPlatforms;
|
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.IrisDimension;
|
||||||
import art.arcane.iris.engine.object.IrisWorld;
|
import art.arcane.iris.engine.object.IrisWorld;
|
||||||
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
|
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
|
||||||
import art.arcane.iris.util.common.plugin.VolmitPlugin;
|
import art.arcane.iris.util.common.plugin.VolmitPlugin;
|
||||||
import lombok.NonNull;
|
import lombok.NonNull;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.NamespacedKey;
|
import org.bukkit.NamespacedKey;
|
||||||
import org.bukkit.generator.BiomeProvider;
|
import org.bukkit.generator.BiomeProvider;
|
||||||
import org.bukkit.generator.ChunkGenerator;
|
import org.bukkit.generator.ChunkGenerator;
|
||||||
@@ -231,9 +231,18 @@ public final class IrisWorldGeneratorResolver {
|
|||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
public static IrisDimension loadDimension(@NonNull String worldName, @NonNull String id) {
|
public static IrisDimension loadDimension(@NonNull String worldName, @NonNull String id) {
|
||||||
NamespacedKey worldKey = configuredWorldKey(worldName, IrisWorldStorage.levelRoot().getName());
|
File levelRoot = IrisWorldStorage.levelRoot();
|
||||||
File pack = IrisWorldStorage.packRoot(worldKey);
|
NamespacedKey worldKey = configuredWorldKey(worldName, levelRoot.getName());
|
||||||
IrisDimension dimension = pack.isDirectory() ? IrisData.get(pack).getDimensionLoader().load(id) : null;
|
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) dimension = IrisData.loadAnyDimension(id, null);
|
||||||
if (dimension == null) {
|
if (dimension == null) {
|
||||||
File packsRoot = IrisPlatforms.get().packsFolderNoCreate();
|
File packsRoot = IrisPlatforms.get().packsFolderNoCreate();
|
||||||
@@ -279,57 +288,63 @@ public final class IrisWorldGeneratorResolver {
|
|||||||
if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType();
|
if (id == null || id.isEmpty()) id = IrisSettings.get().getGenerator().getDefaultWorldType();
|
||||||
Iris.debug("Generator ID: " + id + " requested by bukkit/plugin");
|
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 {
|
try {
|
||||||
if (snapshotRoot.toPath().toAbsolutePath().normalize()
|
return resolveFrozenWorldGenerator(worldName, id);
|
||||||
.equals(dimensionPackRoot.toPath().toAbsolutePath().normalize())) {
|
} catch (RuntimeException failure) {
|
||||||
requireSnapshotLoadable(snapshotRoot);
|
Iris.reportError("Refusing to load configured Iris world '" + worldName
|
||||||
} else {
|
+ "' because its frozen world-local pack snapshot could not be used.", failure);
|
||||||
PackValidationRegistry.requireLoadable(packName);
|
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) {
|
} 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()) {
|
for (String reason : exception.getReasons()) {
|
||||||
Iris.error(" - " + reason);
|
Iris.error(" - " + reason);
|
||||||
}
|
}
|
||||||
throw exception;
|
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())
|
.platformIdentity(worldKey.toString())
|
||||||
.name(worldName)
|
.name(worldName)
|
||||||
.seed(1337)
|
.seed(1337)
|
||||||
.worldFolder(IrisWorldStorage.dimensionRoot(worldKey))
|
.worldFolder(dimensionRoot)
|
||||||
.minHeight(dim.getMinHeight())
|
.minHeight(dimension.getMinHeight())
|
||||||
.maxHeight(dim.getMaxHeight())
|
.maxHeight(dimension.getMaxHeight())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
Iris.debug("Generator Config: " + w.toString());
|
Iris.debug("Generator Config: " + world);
|
||||||
|
|
||||||
File ff = new File(w.worldFolder(), "iris/pack");
|
return new BukkitChunkGenerator(world, false, snapshotRoot, dimension.getLoadKey());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private record FreshValidation(
|
private record FreshValidation(
|
||||||
|
|||||||
+54
@@ -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."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-2
@@ -451,18 +451,51 @@ public final class PendingWorldDeleteQueue implements WorldDeletionQueue {
|
|||||||
|
|
||||||
if (type == QueueEntryType.EXACT) {
|
if (type == QueueEntryType.EXACT) {
|
||||||
NamespacedKey key = IrisWorldStorage.managedKeyFromName(storedName, levelRoot.getName());
|
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));
|
return List.of(new DeleteTarget(key, path));
|
||||||
}
|
}
|
||||||
|
|
||||||
ArrayList<DeleteTarget> targets = new ArrayList<>(3);
|
ArrayList<DeleteTarget> targets = new ArrayList<>(3);
|
||||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(storedName)) {
|
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(storedName)) {
|
||||||
NamespacedKey key = IrisWorldStorage.managedKeyFromName(familyWorldName, levelRoot.getName());
|
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));
|
targets.add(new DeleteTarget(key, path));
|
||||||
}
|
}
|
||||||
return targets;
|
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 {
|
private static Path requireSafeQuarantinePath(File levelRoot, String quarantineName) throws IOException {
|
||||||
|
|||||||
+419
@@ -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.LifecycleOperationCoordinator;
|
||||||
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap;
|
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap;
|
||||||
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker;
|
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;
|
||||||
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
|
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
|
||||||
import art.arcane.iris.core.lifecycle.WorldReplacementJournal;
|
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.WorldReplacementJournal.Transaction;
|
||||||
import art.arcane.iris.core.lifecycle.WorldReplacementSeed;
|
import art.arcane.iris.core.lifecycle.WorldReplacementSeed;
|
||||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
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.service.StudioSVC;
|
||||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||||
import art.arcane.iris.engine.object.IrisDimension;
|
import art.arcane.iris.engine.object.IrisDimension;
|
||||||
import art.arcane.iris.engine.object.IrisEnvironment;
|
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.engine.platform.PlatformChunkGenerator;
|
||||||
import art.arcane.iris.util.common.misc.ServerProperties;
|
import art.arcane.iris.util.common.misc.ServerProperties;
|
||||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||||
import art.arcane.iris.util.common.scheduling.J;
|
import art.arcane.iris.util.common.scheduling.J;
|
||||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
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.NamespacedKey;
|
||||||
import org.bukkit.World;
|
import org.bukkit.World;
|
||||||
import org.bukkit.event.EventHandler;
|
import org.bukkit.event.EventHandler;
|
||||||
import org.bukkit.event.EventPriority;
|
import org.bukkit.event.EventPriority;
|
||||||
import org.bukkit.event.Listener;
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
import org.bukkit.event.world.WorldLoadEvent;
|
import org.bukkit.event.world.WorldLoadEvent;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.lang.reflect.InvocationTargetException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -40,23 +49,60 @@ import java.util.HashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.OptionalLong;
|
import java.util.OptionalLong;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.TimeoutException;
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
public final class PendingWorldReplacementManager implements Listener {
|
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 Iris plugin;
|
||||||
private final Set<UUID> cleanupInFlight = new HashSet<>();
|
private final Set<UUID> cleanupInFlight = new HashSet<>();
|
||||||
private final Set<UUID> verificationInFlight = 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) {
|
public PendingWorldReplacementManager(Iris plugin) {
|
||||||
this.plugin = Objects.requireNonNull(plugin, "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) {
|
public NamespacedKey resolveRequestedWorldKey(String requestedName) {
|
||||||
NamespacedKey worldKey = IrisWorldStorage.replacementKeyFromName(
|
NamespacedKey worldKey = IrisWorldStorage.replacementKeyFromName(
|
||||||
requestedName,
|
requestedName,
|
||||||
@@ -129,6 +175,9 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
paths.stage(),
|
paths.stage(),
|
||||||
seedSelection
|
seedSelection
|
||||||
);
|
);
|
||||||
|
if (target.slotKind() == SlotKind.VANILLA_OVERWORLD) {
|
||||||
|
WorldReplacementEntryGuard.stage(target.levelRoot(), paths.stage(), transactionId);
|
||||||
|
}
|
||||||
File stagedPack = paths.stage().resolve("iris/pack").toFile();
|
File stagedPack = paths.stage().resolve("iris/pack").toFile();
|
||||||
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
|
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
|
||||||
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
|
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
|
||||||
@@ -205,6 +254,20 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
public synchronized void processPendingStartupReplacements() {
|
public synchronized void processPendingStartupReplacements() {
|
||||||
ArrayList<String> failures = new ArrayList<>();
|
ArrayList<String> failures = new ArrayList<>();
|
||||||
ArrayList<String> restartBoundaries = 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;
|
List<Transaction> transactions;
|
||||||
try {
|
try {
|
||||||
transactions = loadTransactions();
|
transactions = loadTransactions();
|
||||||
@@ -215,6 +278,9 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (Transaction transaction : transactions) {
|
for (Transaction transaction : transactions) {
|
||||||
|
if (OVERWORLD_KEY.equals(transaction.worldKey()) && transaction.phase() == Phase.PUBLISHED) {
|
||||||
|
overworldEntryAwaitingVerification = transaction.id();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
inspectStartupTransaction(transaction);
|
inspectStartupTransaction(transaction);
|
||||||
} catch (RestartBoundaryRequired boundary) {
|
} catch (RestartBoundaryRequired boundary) {
|
||||||
@@ -229,6 +295,7 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
Iris.reportError(message, failure);
|
Iris.reportError(message, failure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
scheduleLoadedOverworldEntryPreparation();
|
||||||
if (!failures.isEmpty()) {
|
if (!failures.isEmpty()) {
|
||||||
IrisStartupValidation.markPacksInvalid(failures);
|
IrisStartupValidation.markPacksInvalid(failures);
|
||||||
}
|
}
|
||||||
@@ -258,6 +325,245 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
scheduleRuntimeCapture(transaction, 0);
|
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)
|
@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);
|
Iris.reportError("Failed to capture a loaded world identity for replacement verification.", failure);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (OVERWORLD_KEY.equals(worldKey)) {
|
||||||
|
prepareOverworldEntry(event.getWorld());
|
||||||
|
}
|
||||||
J.a(() -> discoverLoadedWorldTransaction(worldKey));
|
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) {
|
private void discoverLoadedWorldTransaction(WorldSlotKey worldKey) {
|
||||||
Transaction transaction;
|
Transaction transaction;
|
||||||
try {
|
try {
|
||||||
@@ -412,6 +790,10 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
try {
|
try {
|
||||||
writeTransaction(committed);
|
writeTransaction(committed);
|
||||||
Iris.success("Committed Iris world replacement for " + transaction.worldKey() + ".");
|
Iris.success("Committed Iris world replacement for " + transaction.worldKey() + ".");
|
||||||
|
if (OVERWORLD_KEY.equals(transaction.worldKey())) {
|
||||||
|
overworldEntryAwaitingVerification = null;
|
||||||
|
scheduleLoadedOverworldEntryPreparation();
|
||||||
|
}
|
||||||
scheduleCommittedCleanup(committed);
|
scheduleCommittedCleanup(committed);
|
||||||
} catch (Throwable failure) {
|
} catch (Throwable failure) {
|
||||||
Iris.reportError("The replacement for " + transaction.worldKey()
|
Iris.reportError("The replacement for " + transaction.worldKey()
|
||||||
@@ -456,6 +838,9 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
private void initiateRollback(Transaction transaction, Throwable failure) {
|
private void initiateRollback(Transaction transaction, Throwable failure) {
|
||||||
Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey()
|
Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey()
|
||||||
+ "; the retained world will be restored on restart.", failure);
|
+ "; the retained world will be restored on restart.", failure);
|
||||||
|
if (OVERWORLD_KEY.equals(transaction.worldKey())) {
|
||||||
|
clearOverworldEntryGuard();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING);
|
Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING);
|
||||||
writeTransaction(rollback);
|
writeTransaction(rollback);
|
||||||
@@ -608,6 +993,24 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
return plugin.getDataFolder().toPath().toAbsolutePath().normalize();
|
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 {
|
private ExactWorldSlotPathPolicy.Target resolveTransactionTarget(Transaction transaction) throws IOException {
|
||||||
return WorldReplacementJournal.resolveTarget(transaction, IrisWorldStorage.levelRoot().toPath());
|
return WorldReplacementJournal.resolveTarget(transaction, IrisWorldStorage.levelRoot().toPath());
|
||||||
}
|
}
|
||||||
@@ -738,6 +1141,22 @@ public final class PendingWorldReplacementManager implements Listener {
|
|||||||
private record VanillaLevelContext(boolean allowNether, boolean allowEnd) {
|
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 static final class RestartBoundaryRequired extends IOException {
|
||||||
private RestartBoundaryRequired(String message) {
|
private RestartBoundaryRequired(String message) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|||||||
@@ -801,8 +801,14 @@ public class CommandIris implements DirectorExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
boolean doesWorldExist(String worldName) {
|
boolean doesWorldExist(String worldName) {
|
||||||
File worldDirectory = IrisWorldStorage.dimensionRoot(worldName);
|
NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(worldName);
|
||||||
return worldDirectory.exists() && worldDirectory.isDirectory();
|
File levelRoot = IrisWorldStorage.levelRoot();
|
||||||
|
return IrisWorldStorage.frozenDimensionRoot(
|
||||||
|
Bukkit.getWorldContainer(),
|
||||||
|
levelRoot,
|
||||||
|
IrisWorldStorage.configuredWorldName(worldKey, levelRoot.getName()),
|
||||||
|
worldKey
|
||||||
|
).isPresent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ManagedWorldNameHandler implements DirectorParameterHandler<String> {
|
public static class ManagedWorldNameHandler implements DirectorParameterHandler<String> {
|
||||||
|
|||||||
+55
@@ -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 {
|
private static void writeValidPack(Path packRoot) throws Exception {
|
||||||
Files.createDirectories(packRoot.resolve("dimensions"));
|
Files.createDirectories(packRoot.resolve("dimensions"));
|
||||||
Files.createDirectories(packRoot.resolve("regions"));
|
Files.createDirectories(packRoot.resolve("regions"));
|
||||||
|
|||||||
+13
@@ -174,6 +174,19 @@ public class PendingWorldDeleteQueueTest {
|
|||||||
), family);
|
), 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
|
@Test
|
||||||
public void failedSafeDeletionSignalsQueueRetentionAndSucceedsOnRetry() throws IOException {
|
public void failedSafeDeletionSignalsQueueRetentionAndSucceedsOnRetry() throws IOException {
|
||||||
File levelRoot = temporaryFolder.newFolder("retry-world");
|
File levelRoot = temporaryFolder.newFolder("retry-world");
|
||||||
|
|||||||
+78
@@ -164,6 +164,84 @@ public class PendingWorldReplacementThreadAffinityTest {
|
|||||||
assertFalse(irisSource.contains("J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds)"));
|
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(
|
private static PendingWorldReplacementManager.PublishedWorldRuntimeState runtimeState(
|
||||||
WorldSlotKey worldKey,
|
WorldSlotKey worldKey,
|
||||||
long seed,
|
long seed,
|
||||||
|
|||||||
@@ -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 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 minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2'))
|
||||||
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.3'))
|
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 ->
|
Closure<String> irisArtifactName = { String platform, String targetVersion ->
|
||||||
return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
|
return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ Properties rootProperties = new Properties()
|
|||||||
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
|
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
|
||||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.0.0-26.2'))
|
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 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 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:d9026a7c8ebc391c8109f401ce79a0ce65df3969'))
|
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522'))
|
||||||
Closure<String> loaderDisplayVersion = { String loaderVersion ->
|
Closure<String> loaderDisplayVersion = { String loaderVersion ->
|
||||||
String coordinatePrefix = "${minecraftVersion}-"
|
String coordinatePrefix = "${minecraftVersion}-"
|
||||||
if (loaderVersion.startsWith(coordinatePrefix)) {
|
if (loaderVersion.startsWith(coordinatePrefix)) {
|
||||||
|
|||||||
+2
-3
@@ -360,9 +360,8 @@ public final class NativeStructureVolumeIndex {
|
|||||||
|
|
||||||
private static void warnOnce(String structureId, Throwable error) {
|
private static void warnOnce(String structureId, Throwable error) {
|
||||||
if (WARNED_RESOLUTION_FAILURES.add(structureId)) {
|
if (WARNED_RESOLUTION_FAILURES.add(structureId)) {
|
||||||
IrisLogging.warn("Native structure volume resolution failed for '" + structureId
|
IrisLogging.reportError("Native structure volume resolution failed for '" + structureId
|
||||||
+ "'; objects will not be vetoed against it: "
|
+ "'; objects will not be vetoed against it.", error);
|
||||||
+ error.getClass().getSimpleName() + ":" + error.getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ public final class ModdedMixinAudit {
|
|||||||
new ExpectedMixin("MobAwarenessMixin", "entity",
|
new ExpectedMixin("MobAwarenessMixin", "entity",
|
||||||
"net.minecraft.world.entity.Mob", "iris$tickUnawareMob",
|
"net.minecraft.world.entity.Mob", "iris$tickUnawareMob",
|
||||||
false, ModdedMixinFlags::mobAwarenessRan),
|
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",
|
new ExpectedMixin("IrisWorldOpenFlowsMixin", "client",
|
||||||
"net.minecraft.client.gui.screens.worldselection.WorldOpenFlows",
|
"net.minecraft.client.gui.screens.worldselection.WorldOpenFlows",
|
||||||
"iris$openWorldCheckWorldStemCompatibility",
|
"iris$openWorldCheckWorldStemCompatibility",
|
||||||
@@ -103,7 +107,7 @@ public final class ModdedMixinAudit {
|
|||||||
LOGGER.error(" missing: {}", entry);
|
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("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("===============================================================");
|
LOGGER.error("===============================================================");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public final class ModdedMixinFlags {
|
|||||||
private static volatile boolean entityPersistenceRan;
|
private static volatile boolean entityPersistenceRan;
|
||||||
private static volatile boolean livingEntityLootRan;
|
private static volatile boolean livingEntityLootRan;
|
||||||
private static volatile boolean mobAwarenessRan;
|
private static volatile boolean mobAwarenessRan;
|
||||||
|
private static volatile boolean structureTemplatePaletteRan;
|
||||||
private static volatile boolean worldOpenFlowsRan;
|
private static volatile boolean worldOpenFlowsRan;
|
||||||
private static volatile boolean worldTypeEntryRan;
|
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() {
|
public static void markWorldOpenFlows() {
|
||||||
if (!worldOpenFlowsRan) {
|
if (!worldOpenFlowsRan) {
|
||||||
worldOpenFlowsRan = true;
|
worldOpenFlowsRan = true;
|
||||||
@@ -82,6 +89,10 @@ public final class ModdedMixinFlags {
|
|||||||
return mobAwarenessRan;
|
return mobAwarenessRan;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean structureTemplatePaletteRan() {
|
||||||
|
return structureTemplatePaletteRan;
|
||||||
|
}
|
||||||
|
|
||||||
public static boolean worldOpenFlowsRan() {
|
public static boolean worldOpenFlowsRan() {
|
||||||
return worldOpenFlowsRan;
|
return worldOpenFlowsRan;
|
||||||
}
|
}
|
||||||
@@ -94,6 +105,7 @@ public final class ModdedMixinFlags {
|
|||||||
entityPersistenceRan = false;
|
entityPersistenceRan = false;
|
||||||
livingEntityLootRan = false;
|
livingEntityLootRan = false;
|
||||||
mobAwarenessRan = false;
|
mobAwarenessRan = false;
|
||||||
|
structureTemplatePaletteRan = false;
|
||||||
worldOpenFlowsRan = false;
|
worldOpenFlowsRan = false;
|
||||||
worldTypeEntryRan = false;
|
worldTypeEntryRan = false;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-2
@@ -50,10 +50,12 @@ import net.minecraft.core.SectionPos;
|
|||||||
import net.minecraft.core.registries.Registries;
|
import net.minecraft.core.registries.Registries;
|
||||||
import net.minecraft.resources.Identifier;
|
import net.minecraft.resources.Identifier;
|
||||||
import net.minecraft.server.level.ServerLevel;
|
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.ChunkPos;
|
||||||
import net.minecraft.world.level.LevelHeightAccessor;
|
import net.minecraft.world.level.LevelHeightAccessor;
|
||||||
import net.minecraft.world.level.StructureManager;
|
import net.minecraft.world.level.StructureManager;
|
||||||
import net.minecraft.world.level.WorldGenLevel;
|
import net.minecraft.world.level.WorldGenLevel;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
import net.minecraft.world.level.block.state.BlockState;
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
import net.minecraft.world.level.biome.BiomeSource;
|
import net.minecraft.world.level.biome.BiomeSource;
|
||||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||||
@@ -73,6 +75,7 @@ import java.util.Comparator;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
import java.util.function.IntBinaryOperator;
|
import java.util.function.IntBinaryOperator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -346,6 +349,11 @@ final class ModdedNativeStructureStage {
|
|||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!placementGroups.isEmpty()) {
|
||||||
|
ServerLevel level = world.getLevel();
|
||||||
|
visitExistingPois(chunk, (position, state) -> level.updatePOIOnBlockStateChange(
|
||||||
|
position, Blocks.AIR.defaultBlockState(), state));
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
int runtimeMinY = world.getMinY();
|
int runtimeMinY = world.getMinY();
|
||||||
WorldgenTerrainHeightmaps.primeStructurePlacement(
|
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) {
|
private static String nativeStructureBatchContext(List<NativePlacementGroup> placementGroups) {
|
||||||
if (placementGroups.isEmpty()) {
|
if (placementGroups.isEmpty()) {
|
||||||
return "<no resolved native structures>";
|
return "<no resolved native structures>";
|
||||||
@@ -423,9 +435,21 @@ final class ModdedNativeStructureStage {
|
|||||||
WorldgenRandom random, BoundingBox area, ChunkPos chunkPos,
|
WorldgenRandom random, BoundingBox area, ChunkPos chunkPos,
|
||||||
String structureId, StructureStart start,
|
String structureId, StructureStart start,
|
||||||
IrisNativeStructureDecision decision) {
|
IrisNativeStructureDecision decision) {
|
||||||
NativeStructurePostProcessor.place(world, structureManager, generator, random, area, chunkPos,
|
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,
|
structureId, start, decision, this::resolvePaletteBlock,
|
||||||
(x, z) -> generator.engine().getHeight(x, z, true) + generator.engine().getMinHeight());
|
(x, z) -> current.getHeight(x, z, true) + current.getMinHeight());
|
||||||
|
} finally {
|
||||||
|
world.setCurrentlyGenerating(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<List<Structure>> structuresByStep(Registry<Structure> registry) {
|
private List<List<Structure>> structuresByStep(Registry<Structure> registry) {
|
||||||
|
|||||||
+558
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-7
@@ -344,7 +344,7 @@ public final class IrisModdedCommands {
|
|||||||
|
|
||||||
execution = new PackDownloadExecution(
|
execution = new PackDownloadExecution(
|
||||||
lease,
|
lease,
|
||||||
cancellation -> executeDownload(source, request, target, downloadSource, scheduler, cancellation)
|
cancellation -> executeDownload(source, request, target, downloadSource, cancellation)
|
||||||
);
|
);
|
||||||
PackDownloadExecution trackedExecution = execution;
|
PackDownloadExecution trackedExecution = execution;
|
||||||
execution.onCompletion(() -> clearActiveDownload(trackedExecution));
|
execution.onCompletion(() -> clearActiveDownload(trackedExecution));
|
||||||
@@ -384,7 +384,6 @@ public final class IrisModdedCommands {
|
|||||||
DownloadRequest request,
|
DownloadRequest request,
|
||||||
String target,
|
String target,
|
||||||
String downloadSource,
|
String downloadSource,
|
||||||
ModdedScheduler scheduler,
|
|
||||||
PackDownloader.DownloadCancellation cancellation
|
PackDownloader.DownloadCancellation cancellation
|
||||||
) throws PackDownloader.PackDownloadCancelledException {
|
) throws PackDownloader.PackDownloadCancelledException {
|
||||||
File packs = ModdedPackCommands.packsRoot();
|
File packs = ModdedPackCommands.packsRoot();
|
||||||
@@ -394,37 +393,41 @@ public final class IrisModdedCommands {
|
|||||||
packs,
|
packs,
|
||||||
request.url(),
|
request.url(),
|
||||||
false,
|
false,
|
||||||
(String message) -> scheduler.global(() -> ok(source, message)),
|
(String message) -> dispatchDownloadFeedback(source, () -> ok(source, message)),
|
||||||
cancellation
|
cancellation
|
||||||
)
|
)
|
||||||
: PackDownloader.downloadBuiltIn(
|
: PackDownloader.downloadBuiltIn(
|
||||||
packs,
|
packs,
|
||||||
request.pack(),
|
request.pack(),
|
||||||
false,
|
false,
|
||||||
(String message) -> scheduler.global(() -> ok(source, message)),
|
(String message) -> dispatchDownloadFeedback(source, () -> ok(source, message)),
|
||||||
cancellation
|
cancellation
|
||||||
);
|
);
|
||||||
String completionMessage = downloadCompletionMessage(result);
|
String completionMessage = downloadCompletionMessage(result);
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
if (completionMessage != null) {
|
if (completionMessage != null) {
|
||||||
scheduler.global(() -> ok(source, completionMessage));
|
dispatchDownloadFeedback(source, () -> ok(source, completionMessage));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (PackDownloader.PackDownloadCancelledException error) {
|
} catch (PackDownloader.PackDownloadCancelledException error) {
|
||||||
throw error;
|
throw error;
|
||||||
} catch (PackDownloader.PackDownloadBusyException error) {
|
} catch (PackDownloader.PackDownloadBusyException error) {
|
||||||
scheduler.global(() -> fail(source, error.getMessage()));
|
dispatchDownloadFeedback(source, () -> fail(source, error.getMessage()));
|
||||||
return;
|
return;
|
||||||
} catch (IOException | RuntimeException error) {
|
} catch (IOException | RuntimeException error) {
|
||||||
LOGGER.error("Iris pack download failed for {}", target, 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,
|
ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_DOWNLOAD_FAILED_SEE_CONSOLE,
|
||||||
MessageArgument.untrusted("pack", target),
|
MessageArgument.untrusted("pack", target),
|
||||||
MessageArgument.untrusted("downloadSource", downloadSource))));
|
MessageArgument.untrusted("downloadSource", downloadSource))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void dispatchDownloadFeedback(CommandSourceStack source, Runnable feedback) {
|
||||||
|
source.getServer().execute(feedback);
|
||||||
|
}
|
||||||
|
|
||||||
static String downloadBusyMessage(LifecycleOperationCoordinator.ActiveOperation operation) {
|
static String downloadBusyMessage(LifecycleOperationCoordinator.ActiveOperation operation) {
|
||||||
if (operation.domain() == LifecycleOperationCoordinator.Domain.PACK_MUTATION
|
if (operation.domain() == LifecycleOperationCoordinator.Domain.PACK_MUTATION
|
||||||
&& operation.kind() == LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD) {
|
&& operation.kind() == LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD) {
|
||||||
|
|||||||
+8
-1
@@ -51,7 +51,14 @@ final class ModdedPregenCommands {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
boolean showGui = gui && ModdedGuiHost.isGuiLaunchable();
|
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));
|
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_PREGENERATION_TASK_IS_ALREADY_RUNNING_STOP_IT_FIRST_WITH_IRIS));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -55,13 +55,13 @@ public final class ModdedPregenJob {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
PregenPerformanceProfile.apply(engine);
|
|
||||||
PregenTask task = PregenTask.builder()
|
PregenTask task = PregenTask.builder()
|
||||||
.gui(gui)
|
.gui(gui)
|
||||||
.center(new Position2(centerBlockX, centerBlockZ))
|
.center(new Position2(centerBlockX, centerBlockZ))
|
||||||
.radiusX(radiusBlocks)
|
.radiusX(radiusBlocks)
|
||||||
.radiusZ(radiusBlocks)
|
.radiusZ(radiusBlocks)
|
||||||
.build();
|
.build();
|
||||||
|
PregenPerformanceProfile.apply(engine);
|
||||||
ModdedPregenMethod moddedMethod = new ModdedPregenMethod(level, engine, sync);
|
ModdedPregenMethod moddedMethod = new ModdedPregenMethod(level, engine, sync);
|
||||||
PregeneratorMethod method = moddedMethod;
|
PregeneratorMethod method = moddedMethod;
|
||||||
if (cached) {
|
if (cached) {
|
||||||
|
|||||||
+12
-2
@@ -73,6 +73,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
private final AtomicBoolean finalSaveCompleted = new AtomicBoolean(false);
|
private final AtomicBoolean finalSaveCompleted = new AtomicBoolean(false);
|
||||||
private final AtomicReference<FinalSaveRequest> queuedFinalSave = new AtomicReference<>();
|
private final AtomicReference<FinalSaveRequest> queuedFinalSave = new AtomicReference<>();
|
||||||
private final AtomicBoolean stallHintLogged = new AtomicBoolean(false);
|
private final AtomicBoolean stallHintLogged = new AtomicBoolean(false);
|
||||||
|
private final AtomicBoolean failureDetailLogged = new AtomicBoolean(false);
|
||||||
private final int timeoutSeconds;
|
private final int timeoutSeconds;
|
||||||
private final PregenMantleBackpressure backpressure;
|
private final PregenMantleBackpressure backpressure;
|
||||||
private final PauseWhenEmptyGuard pauseGuard;
|
private final PauseWhenEmptyGuard pauseGuard;
|
||||||
@@ -350,7 +351,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
if (e instanceof TimeoutException) {
|
if (e instanceof TimeoutException) {
|
||||||
noteStallHint();
|
noteStallHint();
|
||||||
}
|
}
|
||||||
LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, e.toString());
|
logChunkFailure(x, z, e);
|
||||||
listener.onChunkFailed(x, z);
|
listener.onChunkFailed(x, z);
|
||||||
} finally {
|
} finally {
|
||||||
markFinished();
|
markFinished();
|
||||||
@@ -395,7 +396,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
if (unwrap(error) instanceof TimeoutException) {
|
if (unwrap(error) instanceof TimeoutException) {
|
||||||
onTimeout();
|
onTimeout();
|
||||||
}
|
}
|
||||||
LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, error.toString());
|
logChunkFailure(x, z, error);
|
||||||
listener.onChunkFailed(x, z);
|
listener.onChunkFailed(x, z);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -421,6 +422,15 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
inFlightPeak.accumulateAndGet(current, Math::max);
|
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() {
|
private void markFinished() {
|
||||||
inFlight.decrementAndGet();
|
inFlight.decrementAndGet();
|
||||||
if (sync) {
|
if (sync) {
|
||||||
|
|||||||
+31
@@ -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": [
|
"mixins": [
|
||||||
"EntityPersistenceMixin",
|
"EntityPersistenceMixin",
|
||||||
"LivingEntityLootMixin",
|
"LivingEntityLootMixin",
|
||||||
"MobAwarenessMixin"
|
"MobAwarenessMixin",
|
||||||
|
"StructureTemplatePaletteConcurrencyMixin"
|
||||||
],
|
],
|
||||||
"injectors": {
|
"injectors": {
|
||||||
"defaultRequire": 1
|
"defaultRequire": 1
|
||||||
|
|||||||
+33
@@ -102,7 +102,12 @@ public class ModdedGenerationLeaseContractTest {
|
|||||||
String execution = method(source, "private static void executeDownload(");
|
String execution = method(source, "private static void executeDownload(");
|
||||||
assertTrue(execution.contains("PackDownloader.DownloadCancellation cancellation"));
|
assertTrue(execution.contains("PackDownloader.DownloadCancellation cancellation"));
|
||||||
assertTrue(execution.contains("catch (PackDownloader.PackDownloadCancelledException error)"));
|
assertTrue(execution.contains("catch (PackDownloader.PackDownloadCancelledException error)"));
|
||||||
|
assertTrue(execution.contains("dispatchDownloadFeedback(source,"));
|
||||||
|
assertFalse(execution.contains("scheduler.global("));
|
||||||
assertFalse(execution.contains("lease.close();"));
|
assertFalse(execution.contains("lease.close();"));
|
||||||
|
|
||||||
|
String feedback = method(source, "private static void dispatchDownloadFeedback(");
|
||||||
|
assertTrue(feedback.contains("source.getServer().execute(feedback);"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -170,6 +175,34 @@ public class ModdedGenerationLeaseContractTest {
|
|||||||
assertTrue(pending.contains("queuedFinalSave.get() != null"));
|
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 {
|
private static String source(String relativePath) throws IOException {
|
||||||
String root = System.getProperty(SOURCE_ROOT_PROPERTY);
|
String root = System.getProperty(SOURCE_ROOT_PROPERTY);
|
||||||
assertTrue("Missing system property " + SOURCE_ROOT_PROPERTY, root != null && !root.isBlank());
|
assertTrue("Missing system property " + SOURCE_ROOT_PROPERTY, root != null && !root.isBlank());
|
||||||
|
|||||||
+169
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+323
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -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<>();"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -67,6 +67,11 @@ public class NativeStructureFailureContractTest {
|
|||||||
assertTrue(placement.contains("\"terrain preparation\""));
|
assertTrue(placement.contains("\"terrain preparation\""));
|
||||||
assertTrue(placement.contains("\"foundation repair\""));
|
assertTrue(placement.contains("\"foundation repair\""));
|
||||||
assertFalse(placement.contains("\"terrain carving\""));
|
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("prepareSurfaceStructures"));
|
||||||
assertTrue(placement.contains("clearIntersectingVegetation"));
|
assertTrue(placement.contains("clearIntersectingVegetation"));
|
||||||
assertTrue(placement.indexOf("clearIntersectingVegetation")
|
assertTrue(placement.indexOf("clearIntersectingVegetation")
|
||||||
@@ -95,6 +100,19 @@ public class NativeStructureFailureContractTest {
|
|||||||
assertTrue(source.contains("int minY = chunk.getMinY() + 1;"));
|
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
|
@Test
|
||||||
public void structureFailurePreservesPhaseIdentityChunkAndCause() {
|
public void structureFailurePreservesPhaseIdentityChunkAndCause() {
|
||||||
IllegalArgumentException cause = new IllegalArgumentException("broken placement");
|
IllegalArgumentException cause = new IllegalArgumentException("broken placement");
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ Properties rootProperties = new Properties()
|
|||||||
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
|
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
|
||||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.0.0-26.2'))
|
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 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 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:d9026a7c8ebc391c8109f401ce79a0ce65df3969'))
|
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522'))
|
||||||
Closure<String> irisArtifactName = { String platform, String targetVersion ->
|
Closure<String> irisArtifactName = { String platform, String targetVersion ->
|
||||||
return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
|
return "Iris v${project.version} [${platform}] ${targetVersion}.jar"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -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.
|
// 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 bukkitMinecraftRange = providers.gradleProperty('bukkitMinecraftRange').getOrElse('26.1.2-26.2')
|
||||||
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse('0.19.3')
|
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse('0.19.3')
|
||||||
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.2-65.0.4')
|
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.2-65.1.1')
|
||||||
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.2.0.12-beta')
|
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.2.0.59')
|
||||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||||
.orElse('com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969')
|
.orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')
|
||||||
.get()
|
.get()
|
||||||
String useLocalVolmLib = providers.gradleProperty('useLocalVolmLib').getOrElse('true')
|
String useLocalVolmLib = providers.gradleProperty('useLocalVolmLib').getOrElse('true')
|
||||||
String localVolmLibDirectory = providers.gradleProperty('localVolmLibDirectory').getOrNull()
|
String localVolmLibDirectory = providers.gradleProperty('localVolmLibDirectory').getOrNull()
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ plugins {
|
|||||||
|
|
||||||
def lib = 'art.arcane.iris.util'
|
def lib = 'art.arcane.iris.util'
|
||||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||||
.orElse('com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969')
|
.orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')
|
||||||
.get()
|
.get()
|
||||||
String sentryAuthToken = findProperty('sentry.auth.token') as String ?: System.getenv('SENTRY_AUTH_TOKEN')
|
String sentryAuthToken = findProperty('sentry.auth.token') as String ?: System.getenv('SENTRY_AUTH_TOKEN')
|
||||||
boolean hasSentryAuthToken = sentryAuthToken != null && !sentryAuthToken.isBlank()
|
boolean hasSentryAuthToken = sentryAuthToken != null && !sentryAuthToken.isBlank()
|
||||||
|
|||||||
@@ -289,6 +289,19 @@ public final class IrisWorldStorage {
|
|||||||
return requireSafeManagedDimensionRoot(levelRoot(), key);
|
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) {
|
public static File requireSafeManagedDimensionRoot(File levelRoot, NamespacedKey key) {
|
||||||
NamespacedKey worldKey = Objects.requireNonNull(key, "key");
|
NamespacedKey worldKey = Objects.requireNonNull(key, "key");
|
||||||
if (!IRIS_NAMESPACE.equals(worldKey.getNamespace()) || !worldKey.getKey().matches("[a-z0-9_-]+")) {
|
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) {
|
public static File packRoot(NamespacedKey key) {
|
||||||
return new File(dimensionRoot(key), "iris/pack");
|
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.charset.StandardCharsets;
|
||||||
import java.nio.file.AtomicMoveNotSupportedException;
|
import java.nio.file.AtomicMoveNotSupportedException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.LinkOption;
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.StandardCopyOption;
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
public class IrisWorlds {
|
public class IrisWorlds {
|
||||||
@@ -147,8 +147,9 @@ public class IrisWorlds {
|
|||||||
public synchronized void clean() {
|
public synchronized void clean() {
|
||||||
boolean removed = worlds.entrySet().removeIf(entry -> {
|
boolean removed = worlds.entrySet().removeIf(entry -> {
|
||||||
try {
|
try {
|
||||||
File packRoot = packRoot(entry.getKey());
|
Optional<File> packRoot = packRoot(entry.getKey());
|
||||||
return !new File(packRoot, "dimensions/" + entry.getValue() + ".json").exists();
|
return packRoot.isEmpty()
|
||||||
|
|| !new File(packRoot.get(), "dimensions/" + entry.getValue() + ".json").exists();
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -236,13 +237,21 @@ public class IrisWorlds {
|
|||||||
.equals(configuredWorldName)) {
|
.equals(configuredWorldName)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Path dimensionRoot = IrisWorldStorage.dimensionRoot(root.toFile(), worldKey)
|
Path worldContainer = root.getParent();
|
||||||
.toPath()
|
if (worldContainer == null) {
|
||||||
.toAbsolutePath()
|
throw new IllegalArgumentException("Selected level root has no world container: " + root);
|
||||||
.normalize();
|
}
|
||||||
if (Files.isDirectory(dimensionRoot, LinkOption.NOFOLLOW_LINKS)) {
|
try {
|
||||||
|
if (IrisWorldStorage.frozenDimensionRoot(
|
||||||
|
worldContainer.toFile(),
|
||||||
|
root.toFile(),
|
||||||
|
configuredWorldName,
|
||||||
|
worldKey
|
||||||
|
).isPresent()) {
|
||||||
result.put(configuredWorldName, entry.getValue());
|
result.put(configuredWorldName, entry.getValue());
|
||||||
}
|
}
|
||||||
|
} catch (IllegalStateException ignored) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -270,14 +279,28 @@ public class IrisWorlds {
|
|||||||
return filterBukkitWorldsByStorage(levelRoot, result);
|
return filterBukkitWorldsByStorage(levelRoot, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private File packRoot(String worldIdentity) {
|
private Optional<File> packRoot(String worldIdentity) {
|
||||||
NamespacedKey worldKey = WorldIdentity.parse(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) {
|
private IrisDimension loadDimension(String worldIdentity, String id) {
|
||||||
File pack = packRoot(worldIdentity);
|
File pack = packRoot(worldIdentity).orElse(null);
|
||||||
IrisDimension dimension = pack.isDirectory() ? IrisData.get(pack).getDimensionLoader().load(id) : null;
|
IrisDimension dimension = pack == null ? null : IrisData.get(pack).getDimensionLoader().load(id);
|
||||||
if (dimension == null) {
|
if (dimension == null) {
|
||||||
dimension = IrisData.loadAnyDimension(id, null);
|
dimension = IrisData.loadAnyDimension(id, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package art.arcane.iris.core;
|
package art.arcane.iris.core;
|
||||||
|
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.NamespacedKey;
|
import org.bukkit.NamespacedKey;
|
||||||
import org.bukkit.WorldCreator;
|
import org.bukkit.WorldCreator;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WorldCreator.ofKey and WorldCreator#key are Paper-API-only. Once a call throws
|
* WorldCreator.ofKey and WorldCreator#key are Paper-API-only. Once a call throws
|
||||||
* NoSuchMethodError (plain Spigot/CraftBukkit) this flips and every later call goes
|
* NoSuchMethodError (plain Spigot/CraftBukkit) this flips and every later call goes
|
||||||
* straight to the fallback. The fallback derives names/keys through IrisWorldStorage's
|
* 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 {
|
public final class WorldCreatorCompat {
|
||||||
private static volatile boolean keyedCreatorsUnavailable;
|
private static volatile boolean keyedCreatorsUnavailable;
|
||||||
@@ -16,14 +20,45 @@ public final class WorldCreatorCompat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static WorldCreator ofKey(NamespacedKey worldKey) {
|
public static WorldCreator ofKey(NamespacedKey worldKey) {
|
||||||
if (!keyedCreatorsUnavailable) {
|
return ofKey(worldKey, IrisWorldStorage.logicalName(worldKey));
|
||||||
try {
|
|
||||||
return WorldCreator.ofKey(worldKey);
|
|
||||||
} catch (NoSuchMethodError e) {
|
|
||||||
keyedCreatorsUnavailable = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
public static NamespacedKey keyOf(WorldCreator creator) {
|
||||||
@@ -34,14 +69,33 @@ public final class WorldCreatorCompat {
|
|||||||
keyedCreatorsUnavailable = true;
|
keyedCreatorsUnavailable = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return IrisWorldStorage.keyFromName(creator.name());
|
return IrisWorldStorage.keyFromConfiguredWorldName(
|
||||||
|
creator.name(),
|
||||||
|
IrisWorldStorage.levelRoot().getName()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String fallbackName(NamespacedKey worldKey, String levelName) {
|
static String fallbackName(NamespacedKey worldKey, String levelName) {
|
||||||
return IrisWorldStorage.logicalName(worldKey, 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) {
|
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,
|
throw new Rejection(RejectionReason.SYMBOLIC_LINK,
|
||||||
"World storage path contains a symbolic link: " + normalizedLevelRoot);
|
"World storage path contains a symbolic link: " + normalizedLevelRoot);
|
||||||
}
|
}
|
||||||
Path target;
|
StorageLayout storageLayout;
|
||||||
try {
|
try {
|
||||||
target = IrisWorldStorage.requireSafeManagedDimensionRoot(
|
storageLayout = resolveStorageLayout(normalizedLevelRoot, worldKey);
|
||||||
normalizedLevelRoot.toFile(),
|
} catch (RuntimeException failure) {
|
||||||
worldKey
|
|
||||||
).toPath().toAbsolutePath().normalize();
|
|
||||||
} catch (IllegalArgumentException failure) {
|
|
||||||
throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure);
|
throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure);
|
||||||
}
|
}
|
||||||
validateStoragePath(normalizedLevelRoot, worldKey, target);
|
validateStoragePath(normalizedLevelRoot, worldKey, storageLayout.dimensionDirectory());
|
||||||
|
validateStorageRoot(normalizedLevelRoot, worldKey, storageLayout.storageDirectory());
|
||||||
return new Target(
|
return new Target(
|
||||||
requestedIdentifier,
|
requestedIdentifier,
|
||||||
worldKey,
|
worldKey,
|
||||||
IrisWorldStorage.logicalName(worldKey, mainWorld),
|
IrisWorldStorage.logicalName(worldKey, mainWorld),
|
||||||
normalizedLevelRoot,
|
normalizedLevelRoot,
|
||||||
target
|
storageLayout.dimensionDirectory(),
|
||||||
|
storageLayout.storageDirectory()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,11 +76,11 @@ public final class WorldRemovalPathPolicy {
|
|||||||
}
|
}
|
||||||
Path expected;
|
Path expected;
|
||||||
try {
|
try {
|
||||||
expected = IrisWorldStorage.requireSafeManagedDimensionRoot(
|
expected = resolveStorageLayout(
|
||||||
normalizedLevelRoot.toFile(),
|
normalizedLevelRoot,
|
||||||
Objects.requireNonNull(worldKey, "worldKey")
|
Objects.requireNonNull(worldKey, "worldKey")
|
||||||
).toPath().toAbsolutePath().normalize();
|
).dimensionDirectory();
|
||||||
} catch (IllegalArgumentException failure) {
|
} catch (RuntimeException failure) {
|
||||||
throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure);
|
throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure);
|
||||||
}
|
}
|
||||||
Path normalizedCandidate = Objects.requireNonNull(candidate, "candidate").toAbsolutePath().normalize();
|
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) {
|
private static Rejection classifyIdentifierFailure(String identifier, IllegalArgumentException failure) {
|
||||||
NamespacedKey parsed = identifier.contains(":")
|
NamespacedKey parsed = identifier.contains(":")
|
||||||
? NamespacedKey.fromString(identifier.toLowerCase(Locale.ENGLISH))
|
? NamespacedKey.fromString(identifier.toLowerCase(Locale.ENGLISH))
|
||||||
@@ -104,12 +125,14 @@ public final class WorldRemovalPathPolicy {
|
|||||||
private static Rejection classifyStorageFailure(
|
private static Rejection classifyStorageFailure(
|
||||||
Path levelRoot,
|
Path levelRoot,
|
||||||
NamespacedKey worldKey,
|
NamespacedKey worldKey,
|
||||||
IllegalArgumentException failure
|
RuntimeException failure
|
||||||
) {
|
) {
|
||||||
Path dimensions = levelRoot.resolve("dimensions");
|
Path dimensions = levelRoot.resolve("dimensions");
|
||||||
Path namespace = dimensions.resolve(worldKey.getNamespace());
|
Path namespace = dimensions.resolve(worldKey.getNamespace());
|
||||||
Path target = namespace.resolve(worldKey.getKey());
|
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(namespace)
|
||||||
|| Files.isSymbolicLink(target)
|
|| Files.isSymbolicLink(target)
|
||||||
? RejectionReason.SYMBOLIC_LINK
|
? RejectionReason.SYMBOLIC_LINK
|
||||||
@@ -117,6 +140,45 @@ public final class WorldRemovalPathPolicy {
|
|||||||
return new Rejection(reason, failure.getMessage(), failure);
|
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) {
|
private static String requireIdentifier(String identifier) {
|
||||||
if (identifier == null || identifier.isBlank()) {
|
if (identifier == null || identifier.isBlank()) {
|
||||||
throw new Rejection(RejectionReason.INVALID_IDENTIFIER, "The world identifier cannot be empty.");
|
throw new Rejection(RejectionReason.INVALID_IDENTIFIER, "The world identifier cannot be empty.");
|
||||||
@@ -129,7 +191,8 @@ public final class WorldRemovalPathPolicy {
|
|||||||
NamespacedKey worldKey,
|
NamespacedKey worldKey,
|
||||||
String logicalName,
|
String logicalName,
|
||||||
Path levelRoot,
|
Path levelRoot,
|
||||||
Path worldDirectory
|
Path worldDirectory,
|
||||||
|
Path storageDirectory
|
||||||
) {
|
) {
|
||||||
public Target {
|
public Target {
|
||||||
Objects.requireNonNull(requestedIdentifier, "requestedIdentifier");
|
Objects.requireNonNull(requestedIdentifier, "requestedIdentifier");
|
||||||
@@ -137,9 +200,13 @@ public final class WorldRemovalPathPolicy {
|
|||||||
Objects.requireNonNull(logicalName, "logicalName");
|
Objects.requireNonNull(logicalName, "logicalName");
|
||||||
Objects.requireNonNull(levelRoot, "levelRoot");
|
Objects.requireNonNull(levelRoot, "levelRoot");
|
||||||
Objects.requireNonNull(worldDirectory, "worldDirectory");
|
Objects.requireNonNull(worldDirectory, "worldDirectory");
|
||||||
|
Objects.requireNonNull(storageDirectory, "storageDirectory");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record StorageLayout(Path dimensionDirectory, Path storageDirectory) {
|
||||||
|
}
|
||||||
|
|
||||||
public enum RejectionReason {
|
public enum RejectionReason {
|
||||||
INVALID_IDENTIFIER,
|
INVALID_IDENTIFIER,
|
||||||
CONFIGURED_MAIN_WORLD,
|
CONFIGURED_MAIN_WORLD,
|
||||||
|
|||||||
@@ -165,10 +165,22 @@ public final class BukkitWorldConfiguration {
|
|||||||
))) {
|
))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!IrisWorldStorage.isExistingManagedDimensionRoot(
|
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(),
|
requiredLevelRoot.toFile(),
|
||||||
|
configuredName,
|
||||||
namespacedKey
|
namespacedKey
|
||||||
)) {
|
).isPresent();
|
||||||
|
} catch (IllegalStateException failure) {
|
||||||
|
storagePresent = false;
|
||||||
|
}
|
||||||
|
if (!storagePresent) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String dimension = selectedIrisDimension(configuredGenerator, configuredName);
|
String dimension = selectedIrisDimension(configuredGenerator, configuredName);
|
||||||
|
|||||||
@@ -852,7 +852,9 @@ public final class IrisWorldRemovalService {
|
|||||||
) {
|
) {
|
||||||
return onGlobal(() -> {
|
return onGlobal(() -> {
|
||||||
requireNotTerminal(terminal, "Multiverse unregistration");
|
requireNotTerminal(terminal, "Multiverse unregistration");
|
||||||
return IrisServices.get(MultiverseCoreLink.class).removeFromConfig(target.logicalName());
|
return IrisServices.get(MultiverseCoreLink.class).removeFromConfig(
|
||||||
|
bukkitConfigurationWorldName(target)
|
||||||
|
);
|
||||||
}).thenCompose(multiverseChanged -> {
|
}).thenCompose(multiverseChanged -> {
|
||||||
if (terminal.getAsBoolean()) {
|
if (terminal.getAsBoolean()) {
|
||||||
return CompletableFuture.failedFuture(new IllegalStateException(
|
return CompletableFuture.failedFuture(new IllegalStateException(
|
||||||
@@ -892,7 +894,10 @@ public final class IrisWorldRemovalService {
|
|||||||
WorldRemovalPathPolicy.Target target,
|
WorldRemovalPathPolicy.Target target,
|
||||||
BooleanSupplier terminal
|
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(
|
return CompletableFuture.supplyAsync(
|
||||||
() -> {
|
() -> {
|
||||||
requireNotTerminal(terminal, "deletion intent");
|
requireNotTerminal(terminal, "deletion intent");
|
||||||
@@ -952,7 +957,12 @@ public final class IrisWorldRemovalService {
|
|||||||
target.worldKey(),
|
target.worldKey(),
|
||||||
target.worldDirectory()
|
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);
|
YamlConfiguration configuration = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
|
||||||
String generator = bukkitGenerator(configuration, target);
|
String generator = bukkitGenerator(configuration, target);
|
||||||
boolean configurationManaged = generator != null
|
boolean configurationManaged = generator != null
|
||||||
@@ -1020,17 +1030,24 @@ public final class IrisWorldRemovalService {
|
|||||||
target.worldKey(),
|
target.worldKey(),
|
||||||
target.worldDirectory()
|
target.worldDirectory()
|
||||||
);
|
);
|
||||||
Path worldDirectory = target.worldDirectory();
|
WorldRemovalPathPolicy.validateStorageRoot(
|
||||||
if (!Files.exists(worldDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
target.levelRoot(),
|
||||||
|
target.worldKey(),
|
||||||
|
target.storageDirectory()
|
||||||
|
);
|
||||||
|
Path storageDirectory = target.storageDirectory();
|
||||||
|
if (!Files.exists(storageDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!Files.isDirectory(worldDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
if (!Files.isDirectory(storageDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
throw new RemovalFailure(
|
throw new RemovalFailure(
|
||||||
RemovalStatus.QUARANTINE_FAILED,
|
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);
|
WorldDeletionQueue deletionQueue = IrisServices.getOrNull(WorldDeletionQueue.class);
|
||||||
if (deletionQueue == null) {
|
if (deletionQueue == null) {
|
||||||
throw new RemovalFailure(
|
throw new RemovalFailure(
|
||||||
@@ -1059,21 +1076,26 @@ public final class IrisWorldRemovalService {
|
|||||||
target.worldKey(),
|
target.worldKey(),
|
||||||
target.worldDirectory()
|
target.worldDirectory()
|
||||||
);
|
);
|
||||||
Path worldDirectory = target.worldDirectory();
|
WorldRemovalPathPolicy.validateStorageRoot(
|
||||||
if (!Files.exists(worldDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
target.levelRoot(),
|
||||||
|
target.worldKey(),
|
||||||
|
target.storageDirectory()
|
||||||
|
);
|
||||||
|
Path storageDirectory = target.storageDirectory();
|
||||||
|
if (!Files.exists(storageDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!Files.isDirectory(worldDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
if (!Files.isDirectory(storageDirectory, LinkOption.NOFOLLOW_LINKS)) {
|
||||||
throw new RemovalFailure(
|
throw new RemovalFailure(
|
||||||
RemovalStatus.QUARANTINE_FAILED,
|
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 {
|
||||||
try {
|
try {
|
||||||
Files.move(worldDirectory, quarantine, StandardCopyOption.ATOMIC_MOVE);
|
Files.move(storageDirectory, quarantine, StandardCopyOption.ATOMIC_MOVE);
|
||||||
} catch (AtomicMoveNotSupportedException unsupported) {
|
} catch (AtomicMoveNotSupportedException unsupported) {
|
||||||
Files.move(worldDirectory, quarantine);
|
Files.move(storageDirectory, quarantine);
|
||||||
}
|
}
|
||||||
return quarantine;
|
return quarantine;
|
||||||
} catch (IOException failure) {
|
} 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) {
|
private DeleteDisposition deleteQuarantine(Path quarantine) {
|
||||||
try {
|
try {
|
||||||
SnapshotDirectoryTreeDeleter.delete(quarantine);
|
SnapshotDirectoryTreeDeleter.delete(quarantine);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public record WorldLifecycleRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public WorldCreator toWorldCreator() {
|
public WorldCreator toWorldCreator() {
|
||||||
WorldCreator creator = WorldCreatorCompat.ofKey(worldKey)
|
WorldCreator creator = WorldCreatorCompat.ofKey(worldKey, worldName)
|
||||||
.environment(environment)
|
.environment(environment)
|
||||||
.generateStructures(generateStructures)
|
.generateStructures(generateStructures)
|
||||||
.hardcore(hardcore)
|
.hardcore(hardcore)
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ public final class WorldLifecycleStaging {
|
|||||||
return stagedStemGenerators.remove(worldName);
|
return stagedStemGenerators.remove(worldName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public static ChunkGenerator peekStemGenerator(@NotNull String worldName) {
|
||||||
|
return stagedStemGenerators.get(worldName);
|
||||||
|
}
|
||||||
|
|
||||||
public static void clearGenerator(@NotNull String worldName) {
|
public static void clearGenerator(@NotNull String worldName) {
|
||||||
stagedGenerators.remove(worldName);
|
stagedGenerators.remove(worldName);
|
||||||
stagedBiomeProviders.remove(worldName);
|
stagedBiomeProviders.remove(worldName);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package art.arcane.iris.core.lifecycle;
|
package art.arcane.iris.core.lifecycle;
|
||||||
|
|
||||||
import art.arcane.iris.core.ExactWorldSlotPathPolicy;
|
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.BukkitWorldConfiguration.WorldGeneratorSnapshot;
|
||||||
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
|
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
|
||||||
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
|
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.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.LinkOption;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
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.");
|
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
refreshOverworldEntryGuard(levelRoot, active, paths);
|
||||||
WorldReplacementFilesystem.publish(
|
WorldReplacementFilesystem.publish(
|
||||||
paths,
|
paths,
|
||||||
active.originalTargetPresent(),
|
active.originalTargetPresent(),
|
||||||
@@ -166,6 +170,7 @@ public final class WorldReplacementBootstrap {
|
|||||||
}
|
}
|
||||||
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
|
throw conflict(active, "bukkit.yml matches neither the replacement nor its retained original state.");
|
||||||
}
|
}
|
||||||
|
refreshOverworldEntryGuard(levelRoot, active, paths);
|
||||||
WorldReplacementFilesystem.publish(
|
WorldReplacementFilesystem.publish(
|
||||||
paths,
|
paths,
|
||||||
active.originalTargetPresent(),
|
active.originalTargetPresent(),
|
||||||
@@ -176,6 +181,20 @@ public final class WorldReplacementBootstrap {
|
|||||||
throw conflict(active, "Replacement journal reached an unsupported bootstrap phase.");
|
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(
|
private static void rollback(
|
||||||
Path dataDirectory,
|
Path dataDirectory,
|
||||||
File bukkitConfiguration,
|
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,10 +418,8 @@ public class IrisPregenerator {
|
|||||||
IrisLogging.reportError(e);
|
IrisLogging.reportError(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (MantleHeapPressure.overPanicWater()) {
|
|
||||||
MantleHeapPressure.requestPanicReclaim();
|
MantleHeapPressure.requestPanicReclaim();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private void checkRegion(int x, int z) {
|
private void checkRegion(int x, int z) {
|
||||||
if (generatedRegions.contains(new Position2(x, z))) {
|
if (generatedRegions.contains(new Position2(x, z))) {
|
||||||
|
|||||||
@@ -18,18 +18,37 @@
|
|||||||
|
|
||||||
package art.arcane.iris.core.pregenerator;
|
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.ManagementFactory;
|
||||||
import java.lang.management.MemoryUsage;
|
import java.lang.management.MemoryUsage;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.function.BiConsumer;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.function.DoubleConsumer;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
public final class MantleHeapPressure {
|
public final class MantleHeapPressure {
|
||||||
private static final double HIGH_WATER = 0.92D;
|
private static final double HIGH_WATER = 0.92D;
|
||||||
private static final double LOW_WATER = 0.82D;
|
private static final double LOW_WATER = 0.82D;
|
||||||
private static final double PANIC_WATER = 0.96D;
|
private static final long MAXIMUM_HYSTERESIS_MS = 60_000L;
|
||||||
private static final long PANIC_GC_INTERVAL_MS = 30_000L;
|
private static final ObjectName DIAGNOSTIC_COMMAND = diagnosticCommandName();
|
||||||
private static final AtomicBoolean engaged = new AtomicBoolean(false);
|
private static final PanicGcReclaimer PANIC_RECLAIMER = new PanicGcReclaimer(
|
||||||
private static final AtomicLong lastPanicGcAt = new AtomicLong(0L);
|
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() {
|
private MantleHeapPressure() {
|
||||||
}
|
}
|
||||||
@@ -50,19 +69,7 @@ public final class MantleHeapPressure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static boolean overHighWater() {
|
public static boolean overHighWater() {
|
||||||
double fraction = usedFraction();
|
return PRESSURE_GATE.update(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static double reclaimUrgency(double fraction) {
|
public static double reclaimUrgency(double fraction) {
|
||||||
@@ -75,18 +82,203 @@ public final class MantleHeapPressure {
|
|||||||
return (fraction - LOW_WATER) / (HIGH_WATER - LOW_WATER);
|
return (fraction - LOW_WATER) / (HIGH_WATER - LOW_WATER);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean overPanicWater() {
|
public static void requestPanicReclaim() {
|
||||||
return usedFraction() >= PANIC_WATER;
|
PANIC_RECLAIMER.request(usedFraction());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void requestPanicReclaim() {
|
static void invokeHotSpotDiagnosticGc(MBeanServer server) throws Exception {
|
||||||
long now = System.currentTimeMillis();
|
if (!server.isRegistered(DIAGNOSTIC_COMMAND)) {
|
||||||
long last = lastPanicGcAt.get();
|
throw new UnsupportedOperationException("HotSpot DiagnosticCommand MBean is not registered on this JVM");
|
||||||
if (now - last < PANIC_GC_INTERVAL_MS) {
|
}
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (lastPanicGcAt.compareAndSet(last, now)) {
|
|
||||||
System.gc();
|
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);
|
IrisLogging.reportError(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (MantleHeapPressure.overPanicWater()) {
|
|
||||||
MantleHeapPressure.requestPanicReclaim();
|
MantleHeapPressure.requestPanicReclaim();
|
||||||
}
|
|
||||||
|
|
||||||
long elapsed = M.ms() - waitStart;
|
long elapsed = M.ms() - waitStart;
|
||||||
if (elapsed >= timeoutMs) {
|
if (elapsed >= timeoutMs) {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import java.util.Map;
|
|||||||
@Builder
|
@Builder
|
||||||
@Data
|
@Data
|
||||||
public class PregenTask {
|
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
|
* 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.
|
* 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) {
|
if (radiusX <= 0 || radiusZ <= 0) {
|
||||||
throw new IllegalArgumentException("Pregen radii must be greater than zero blocks.");
|
throw new IllegalArgumentException("Pregen radii must be greater than zero blocks.");
|
||||||
}
|
}
|
||||||
|
requireWithinWorld(center, radiusX, radiusZ);
|
||||||
|
|
||||||
this.gui = gui;
|
this.gui = gui;
|
||||||
this.center = new ProxiedPos(center);
|
this.center = new ProxiedPos(center);
|
||||||
@@ -79,6 +81,19 @@ public class PregenTask {
|
|||||||
bounds.update();
|
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) {
|
public static void iterateRegion(int xr, int zr, Spiraled s, Position2 pull) {
|
||||||
iterateRegion(xr, zr, s, pull.getX(), pull.getZ());
|
iterateRegion(xr, zr, s, pull.getX(), pull.getZ());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,16 @@ import org.bukkit.Chunk;
|
|||||||
import org.bukkit.GameRule;
|
import org.bukkit.GameRule;
|
||||||
import org.bukkit.HeightMap;
|
import org.bukkit.HeightMap;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
import org.bukkit.World;
|
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.entity.Player;
|
||||||
import org.bukkit.event.world.TimeSkipEvent;
|
import org.bukkit.event.world.TimeSkipEvent;
|
||||||
import org.bukkit.plugin.PluginManager;
|
import org.bukkit.plugin.PluginManager;
|
||||||
|
import org.bukkit.util.BoundingBox;
|
||||||
|
import org.bukkit.util.VoxelShape;
|
||||||
|
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
@@ -31,6 +37,26 @@ import java.util.Set;
|
|||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
public final class WorldRuntimeControlService {
|
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 static volatile WorldRuntimeControlService instance;
|
||||||
|
|
||||||
private final CapabilitySnapshot capabilities;
|
private final CapabilitySnapshot capabilities;
|
||||||
@@ -352,19 +378,138 @@ public final class WorldRuntimeControlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Location findTopSafeLocation(World world, Location source) {
|
static Location findTopSafeLocation(World world, Location source) {
|
||||||
int x = source.getBlockX();
|
int sourceX = source.getBlockX();
|
||||||
int z = source.getBlockZ();
|
int sourceZ = source.getBlockZ();
|
||||||
float yaw = source.getYaw();
|
float yaw = source.getYaw();
|
||||||
float pitch = source.getPitch();
|
float pitch = source.getPitch();
|
||||||
int minY = world.getMinHeight() + 1;
|
int chunkX = sourceX >> 4;
|
||||||
int maxY = world.getMaxHeight() - 2;
|
int chunkZ = sourceZ >> 4;
|
||||||
if (world.isChunkLoaded(x >> 4, z >> 4)) {
|
if (!world.isChunkLoaded(chunkX, chunkZ)) {
|
||||||
int raw = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING_NO_LEAVES);
|
return null;
|
||||||
int y = Math.max(minY, Math.min(maxY, raw + 1));
|
|
||||||
return new Location(world, x + 0.5D, y, z + 0.5D, yaw, pitch);
|
|
||||||
}
|
}
|
||||||
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")
|
@SuppressWarnings("unchecked")
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ public final class EngineMaintenance {
|
|||||||
long unloadStart = System.nanoTime();
|
long unloadStart = System.nanoTime();
|
||||||
int unloadedTectonicPlates = engine.getMantle().unloadTectonicPlate(
|
int unloadedTectonicPlates = engine.getMantle().unloadTectonicPlate(
|
||||||
plan.multicoreUnload() ? 0 : Integer.MAX_VALUE);
|
plan.multicoreUnload() ? 0 : Integer.MAX_VALUE);
|
||||||
if (plan.heapPressure() && MantleHeapPressure.overPanicWater()) {
|
if (plan.heapPressure()) {
|
||||||
MantleHeapPressure.requestPanicReclaim();
|
MantleHeapPressure.requestPanicReclaim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import art.arcane.iris.util.common.plugin.IrisService;
|
|||||||
import art.arcane.volmlib.util.scheduling.Looper;
|
import art.arcane.volmlib.util.scheduling.Looper;
|
||||||
import lombok.NonNull;
|
import lombok.NonNull;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.NamespacedKey;
|
||||||
import org.bukkit.World;
|
import org.bukkit.World;
|
||||||
import org.bukkit.event.EventHandler;
|
import org.bukkit.event.EventHandler;
|
||||||
import org.bukkit.event.EventPriority;
|
import org.bukkit.event.EventPriority;
|
||||||
@@ -145,7 +146,21 @@ public class GlobalCacheSVC implements IrisService {
|
|||||||
|
|
||||||
private static PregenCache createDefault0(String worldIdentity) {
|
private static PregenCache createDefault0(String worldIdentity) {
|
||||||
if (disabled) return PregenCache.EMPTY;
|
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();
|
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.IrisWorlds;
|
||||||
import art.arcane.iris.core.IrisSettings;
|
import art.arcane.iris.core.IrisSettings;
|
||||||
import art.arcane.iris.core.ServerConfigurator;
|
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.BukkitWorldConfiguration;
|
||||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||||
import art.arcane.iris.core.lifecycle.WorldLifecycleCaller;
|
import art.arcane.iris.core.lifecycle.WorldLifecycleCaller;
|
||||||
@@ -165,7 +166,7 @@ public class IrisCreator {
|
|||||||
NamespacedKey worldKey;
|
NamespacedKey worldKey;
|
||||||
try {
|
try {
|
||||||
worldKey = IrisWorldStorage.managedKeyFromName(name);
|
worldKey = IrisWorldStorage.managedKeyFromName(name);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (RuntimeException e) {
|
||||||
throw new IrisException(e.getMessage(), e);
|
throw new IrisException(e.getMessage(), e);
|
||||||
}
|
}
|
||||||
name = IrisWorldStorage.logicalName(worldKey);
|
name = IrisWorldStorage.logicalName(worldKey);
|
||||||
@@ -197,12 +198,19 @@ public class IrisCreator {
|
|||||||
|
|
||||||
private World createReserved(NamespacedKey worldKey, IrisDimension resolvedDimension) throws IrisException {
|
private World createReserved(NamespacedKey worldKey, IrisDimension resolvedDimension) throws IrisException {
|
||||||
File dimensionRoot;
|
File dimensionRoot;
|
||||||
|
File storageRoot;
|
||||||
try {
|
try {
|
||||||
|
if (!studio && !benchmark) {
|
||||||
|
dimensionRoot = WorldCreatorCompat.persistentDimensionRoot(worldKey);
|
||||||
|
storageRoot = WorldCreatorCompat.persistentLevelRoot(worldKey);
|
||||||
|
} else {
|
||||||
dimensionRoot = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
|
dimensionRoot = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
|
||||||
} catch (IllegalArgumentException e) {
|
storageRoot = dimensionRoot;
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
throw new IrisException(e.getMessage(), 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.");
|
throw new IrisException("World \"" + name + "\" already exists or is loaded.");
|
||||||
}
|
}
|
||||||
if (sender == null) {
|
if (sender == null) {
|
||||||
@@ -249,6 +257,7 @@ public class IrisCreator {
|
|||||||
.name(name)
|
.name(name)
|
||||||
.seed(seed)
|
.seed(seed)
|
||||||
.studio(studio)
|
.studio(studio)
|
||||||
|
.persistent(!studio && !benchmark)
|
||||||
.create();
|
.create();
|
||||||
reportStudioTiming("prepare_studio_generator", generatorPrepareStart);
|
reportStudioTiming("prepare_studio_generator", generatorPrepareStart);
|
||||||
reportStudioProgress(0.40D, "install_datapacks");
|
reportStudioProgress(0.40D, "install_datapacks");
|
||||||
@@ -343,7 +352,7 @@ public class IrisCreator {
|
|||||||
}
|
}
|
||||||
return world;
|
return world;
|
||||||
} catch (Throwable failure) {
|
} catch (Throwable failure) {
|
||||||
rollbackWorldCreation(worldKey, world, stagedGenerator, dimensionRoot, bukkitRegistered, failure);
|
rollbackWorldCreation(worldKey, world, stagedGenerator, storageRoot, bukkitRegistered, failure);
|
||||||
if (failure instanceof IrisException irisException) {
|
if (failure instanceof IrisException irisException) {
|
||||||
throw irisException;
|
throw irisException;
|
||||||
}
|
}
|
||||||
@@ -692,7 +701,7 @@ public class IrisCreator {
|
|||||||
NamespacedKey worldKey,
|
NamespacedKey worldKey,
|
||||||
World createdWorld,
|
World createdWorld,
|
||||||
PlatformChunkGenerator stagedGenerator,
|
PlatformChunkGenerator stagedGenerator,
|
||||||
File dimensionRoot,
|
File storageRoot,
|
||||||
boolean bukkitRegistered,
|
boolean bukkitRegistered,
|
||||||
Throwable failure
|
Throwable failure
|
||||||
) {
|
) {
|
||||||
@@ -749,7 +758,12 @@ public class IrisCreator {
|
|||||||
if (bukkitRegistered) {
|
if (bukkitRegistered) {
|
||||||
try {
|
try {
|
||||||
CompletableFuture<Boolean> multiverseRemoval = J.sfut(
|
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) {
|
if (multiverseRemoval == null) {
|
||||||
throw new IllegalStateException("Failed to schedule Multiverse rollback for \"" + name + "\".");
|
throw new IllegalStateException("Failed to schedule Multiverse rollback for \"" + name + "\".");
|
||||||
@@ -782,7 +796,7 @@ public class IrisCreator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
AtomicDirectoryPublisher.deleteTree(dimensionRoot.toPath());
|
AtomicDirectoryPublisher.deleteTree(storageRoot.toPath());
|
||||||
} catch (Throwable rollbackFailure) {
|
} catch (Throwable rollbackFailure) {
|
||||||
failure.addSuppressed(rollbackFailure);
|
failure.addSuppressed(rollbackFailure);
|
||||||
queueRollbackDeletion(name, failure);
|
queueRollbackDeletion(name, failure);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ public class IrisWorldCreator {
|
|||||||
private String dimensionName = null;
|
private String dimensionName = null;
|
||||||
private IrisDimension dimension;
|
private IrisDimension dimension;
|
||||||
private long seed = 1337;
|
private long seed = 1337;
|
||||||
|
private boolean persistent;
|
||||||
|
|
||||||
public IrisWorldCreator() {
|
public IrisWorldCreator() {
|
||||||
|
|
||||||
@@ -75,26 +76,35 @@ public class IrisWorldCreator {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IrisWorldCreator persistent(boolean persistent) {
|
||||||
|
this.persistent = persistent;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public WorldCreator create() {
|
public WorldCreator create() {
|
||||||
IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension;
|
IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension;
|
||||||
NamespacedKey worldKey = IrisWorldStorage.keyFromName(name);
|
NamespacedKey worldKey = IrisWorldStorage.keyFromName(name);
|
||||||
World.Environment environment = findEnvironment();
|
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()
|
IrisWorld w = IrisWorld.builder()
|
||||||
.platformIdentity(worldKey.toString())
|
.platformIdentity(worldKey.toString())
|
||||||
.name(name)
|
.name(creator.name())
|
||||||
.minHeight(dim.getMinHeight())
|
.minHeight(dim.getMinHeight())
|
||||||
.maxHeight(dim.getMaxHeight())
|
.maxHeight(dim.getMaxHeight())
|
||||||
.seed(seed)
|
.seed(seed)
|
||||||
.worldFolder(IrisWorldStorage.dimensionRoot(worldKey))
|
.worldFolder(worldFolder)
|
||||||
.build();
|
.build();
|
||||||
ChunkGenerator g = new BukkitChunkGenerator(w, studio, studio
|
ChunkGenerator g = new BukkitChunkGenerator(w, studio, studio
|
||||||
? dim.getLoader().getDataFolder() :
|
? dim.getLoader().getDataFolder() :
|
||||||
new File(w.worldFolder(), "iris/pack"), dimensionName);
|
new File(w.worldFolder(), "iris/pack"), dimensionName);
|
||||||
|
|
||||||
|
return creator.environment(environment)
|
||||||
return WorldCreatorCompat.ofKey(worldKey)
|
|
||||||
.environment(environment)
|
|
||||||
.generateStructures(true)
|
.generateStructures(true)
|
||||||
.generator(g).seed(seed);
|
.generator(g).seed(seed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,23 +118,33 @@ final class EngineRuntimeBuilder {
|
|||||||
engine.runtime = null;
|
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.runtime = next;
|
||||||
engine.publishedTarget = next.target();
|
engine.publishedTarget = next.target();
|
||||||
engine.getGenerationSessions().activateNextSession();
|
engine.getGenerationSessions().activateNextSession();
|
||||||
engine.lifecycleState = LifecycleState.RUNNING;
|
engine.lifecycleState = LifecycleState.RUNNING;
|
||||||
engine.getClosing().set(false);
|
engine.getClosing().set(false);
|
||||||
engine.backgroundTasks.openBackgroundTaskAdmission();
|
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);
|
scheduleRuntimeTasks(next);
|
||||||
IrisLogging.debug("Engine Setup Complete " + next.cacheId());
|
IrisLogging.debug("Engine Setup Complete " + next.cacheId());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package art.arcane.iris.engine.object;
|
package art.arcane.iris.engine.object;
|
||||||
|
|
||||||
|
import art.arcane.iris.core.loader.IrisData;
|
||||||
import art.arcane.iris.engine.framework.Engine;
|
import art.arcane.iris.engine.framework.Engine;
|
||||||
import art.arcane.iris.util.project.interpolation.IrisInterpolation;
|
import art.arcane.iris.util.project.interpolation.IrisInterpolation;
|
||||||
import art.arcane.volmlib.util.collection.KList;
|
import art.arcane.volmlib.util.collection.KList;
|
||||||
import art.arcane.iris.util.project.noise.CNG;
|
import art.arcane.iris.util.project.noise.CNG;
|
||||||
|
|
||||||
|
import java.lang.ref.WeakReference;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.IdentityHashMap;
|
import java.util.IdentityHashMap;
|
||||||
@@ -14,18 +16,20 @@ import java.util.Map;
|
|||||||
public final class IrisDimensionCarvingResolver {
|
public final class IrisDimensionCarvingResolver {
|
||||||
private static final int MAX_CHILD_DEPTH = 32;
|
private static final int MAX_CHILD_DEPTH = 32;
|
||||||
private static final long CHILD_SEED_SALT = 0x9E3779B97F4A7C15L;
|
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() {
|
private IrisDimensionCarvingResolver() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY) {
|
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) {
|
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)) {
|
if (resolvedState.rootEntriesByWorldY.containsKey(worldY)) {
|
||||||
return resolvedState.rootEntriesByWorldY.get(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) {
|
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) {
|
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) {
|
if (rootEntry == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -103,6 +108,7 @@ public final class IrisDimensionCarvingResolver {
|
|||||||
return entry.getRealBiome(engine.getData());
|
return entry.getRealBiome(engine.getData());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.bind(engine);
|
||||||
if (state.biomeCache.containsKey(entry)) {
|
if (state.biomeCache.containsKey(entry)) {
|
||||||
return state.biomeCache.get(entry);
|
return state.biomeCache.get(entry);
|
||||||
}
|
}
|
||||||
@@ -266,12 +272,48 @@ public final class IrisDimensionCarvingResolver {
|
|||||||
return state.childSeed;
|
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 {
|
public static final class State {
|
||||||
private final Map<Integer, IrisDimensionCarvingEntry> rootEntriesByWorldY = new HashMap<>();
|
private final Map<Integer, IrisDimensionCarvingEntry> rootEntriesByWorldY = new HashMap<>();
|
||||||
private final Map<IrisDimensionCarvingEntry, ParentSelectionPlan> selectionPlans = new IdentityHashMap<>();
|
private final Map<IrisDimensionCarvingEntry, ParentSelectionPlan> selectionPlans = new IdentityHashMap<>();
|
||||||
private final Map<IrisDimensionCarvingEntry, IrisBiome> biomeCache = 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 Map<String, IrisDimensionCarvingEntry> entryIndex;
|
||||||
private Long childSeed;
|
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 {
|
private static final class ParentSelectionPlan {
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
|||||||
|
|
||||||
int minY = world.getMinHeight() + 1;
|
int minY = world.getMinHeight() + 1;
|
||||||
int maxY = world.getMaxHeight() - 2;
|
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()));
|
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));
|
assertFalse(IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, file));
|
||||||
assertTrue(IrisWorldStorage.isExistingManagedDimensionRoot(levelRoot, directory));
|
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("world", "world_iris_moon"), selected.keySet());
|
||||||
assertEquals(Set.of("archive_iris_foreign"), other.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"));
|
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
|
@Test
|
||||||
public void fallbackKeyRoundTripsCreatorName() {
|
public void fallbackKeyRoundTripsCreatorName() {
|
||||||
assertEquals(new NamespacedKey("iris", "compat_world"), WorldCreatorCompat.fallbackKey("compat_world", "world"));
|
assertEquals(new NamespacedKey("iris", "compat_world"), WorldCreatorCompat.fallbackKey("compat_world", "world"));
|
||||||
assertEquals(NamespacedKey.minecraft("overworld"), WorldCreatorCompat.fallbackKey("world", "world"));
|
assertEquals(NamespacedKey.minecraft("overworld"), WorldCreatorCompat.fallbackKey("world", "world"));
|
||||||
assertEquals(NamespacedKey.minecraft("the_nether"), WorldCreatorCompat.fallbackKey("world_nether", "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
|
@Test
|
||||||
|
|||||||
@@ -112,6 +112,23 @@ public class BukkitWorldConfigurationTest {
|
|||||||
)), bindings);
|
)), 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
|
@Test
|
||||||
public void excludesSymlinkedCustomWorldTarget() throws Exception {
|
public void excludesSymlinkedCustomWorldTarget() throws Exception {
|
||||||
File configuration = temporaryFolder.newFile("symlink-admission-bukkit.yml");
|
File configuration = temporaryFolder.newFile("symlink-admission-bukkit.yml");
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ public class WorldLifecycleStagingTest {
|
|||||||
assertSame(generator, WorldLifecycleStaging.consumeStemGenerator("world"));
|
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
|
@Test
|
||||||
public void stagedStemGeneratorCannotBeConsumedByDifferentWorldName() {
|
public void stagedStemGeneratorCannotBeConsumedByDifferentWorldName() {
|
||||||
ChunkGenerator generator = mock(ChunkGenerator.class);
|
ChunkGenerator generator = mock(ChunkGenerator.class);
|
||||||
|
|||||||
@@ -28,6 +28,28 @@ public class WorldRemovalPathPolicyTest {
|
|||||||
levelRoot.resolve("dimensions/iris/iris_world").toAbsolutePath().normalize(),
|
levelRoot.resolve("dimensions/iris/iris_world").toAbsolutePath().normalize(),
|
||||||
target.worldDirectory()
|
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
|
@Test
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ public class WorldReplacementBootstrapTest {
|
|||||||
Path overworldDimension = overworldPaths.stage().resolve("iris/pack/dimensions/overworld.json");
|
Path overworldDimension = overworldPaths.stage().resolve("iris/pack/dimensions/overworld.json");
|
||||||
Files.createDirectories(overworldDimension.getParent());
|
Files.createDirectories(overworldDimension.getParent());
|
||||||
Files.writeString(overworldDimension, "overworld-replacement");
|
Files.writeString(overworldDimension, "overworld-replacement");
|
||||||
|
WorldReplacementEntryGuard.stage(levelRoot, overworldPaths.stage(), overworldId);
|
||||||
String overworldFingerprint = WorldReplacementFilesystem.fingerprintPack(
|
String overworldFingerprint = WorldReplacementFilesystem.fingerprintPack(
|
||||||
overworldPaths.stage().resolve("iris/pack")
|
overworldPaths.stage().resolve("iris/pack")
|
||||||
);
|
);
|
||||||
|
|||||||
+109
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
-17
@@ -9,31 +9,27 @@ import static org.junit.Assert.assertTrue;
|
|||||||
|
|
||||||
public class PregenTaskBoundsOverflowTest {
|
public class PregenTaskBoundsOverflowTest {
|
||||||
@Test
|
@Test
|
||||||
public void farPositiveCenterKeepsRegionBoundsOrdered() {
|
public void farPositiveCenterIsRejectedBeforeTraversal() {
|
||||||
PregenTask task = PregenTask.builder()
|
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> PregenTask.builder()
|
||||||
.center(new Position2(Integer.MAX_VALUE - 16, Integer.MAX_VALUE - 16))
|
.center(new Position2(Integer.MAX_VALUE - 16, Integer.MAX_VALUE - 16))
|
||||||
.radiusX(4096)
|
.radiusX(4096)
|
||||||
.radiusZ(4096)
|
.radiusZ(4096)
|
||||||
.build();
|
.build());
|
||||||
|
|
||||||
int[] bounds = task.regionBounds();
|
assertTrue(failure.getMessage().contains("coordinate limit"));
|
||||||
|
|
||||||
assertTrue("minX must not exceed maxX", bounds[0] <= bounds[2]);
|
|
||||||
assertTrue("minZ must not exceed maxZ", bounds[1] <= bounds[3]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void farNegativeCenterKeepsRegionBoundsOrdered() {
|
public void farNegativeCenterIsRejectedBeforeTraversal() {
|
||||||
PregenTask task = PregenTask.builder()
|
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> PregenTask.builder()
|
||||||
.center(new Position2(Integer.MIN_VALUE + 16, Integer.MIN_VALUE + 16))
|
.center(new Position2(Integer.MIN_VALUE + 16, Integer.MIN_VALUE + 16))
|
||||||
.radiusX(4096)
|
.radiusX(4096)
|
||||||
.radiusZ(4096)
|
.radiusZ(4096)
|
||||||
.build();
|
.build());
|
||||||
|
|
||||||
int[] bounds = task.regionBounds();
|
assertTrue(failure.getMessage().contains("coordinate limit"));
|
||||||
|
|
||||||
assertTrue("minX must not exceed maxX", bounds[0] <= bounds[2]);
|
|
||||||
assertTrue("minZ must not exceed maxZ", bounds[1] <= bounds[3]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -49,11 +45,11 @@ public class PregenTaskBoundsOverflowTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void worldLimitRadiusIsAccepted() {
|
public void exactWorldLimitRadiusIsAccepted() {
|
||||||
PregenTask task = PregenTask.builder()
|
PregenTask task = PregenTask.builder()
|
||||||
.center(new Position2(0, 0))
|
.center(new Position2(0, 0))
|
||||||
.radiusX(30_000_000)
|
.radiusX(PregenTask.MAX_WORLD_BLOCK)
|
||||||
.radiusZ(30_000_000)
|
.radiusZ(PregenTask.MAX_WORLD_BLOCK)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
int[] bounds = task.regionBounds();
|
int[] bounds = task.regionBounds();
|
||||||
@@ -62,6 +58,30 @@ public class PregenTaskBoundsOverflowTest {
|
|||||||
assertEquals(58594, bounds[2]);
|
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
|
@Test
|
||||||
public void ordinaryBoundsAreUnchanged() {
|
public void ordinaryBoundsAreUnchanged() {
|
||||||
PregenTask task = PregenTask.builder()
|
PregenTask task = PregenTask.builder()
|
||||||
@@ -87,4 +107,14 @@ public class PregenTaskBoundsOverflowTest {
|
|||||||
assertEquals("bounds[" + index + "]", expected[index], actual[index]);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+183
-11
@@ -4,18 +4,31 @@ import art.arcane.iris.engine.platform.BukkitChunkGenerator;
|
|||||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||||
import org.bukkit.HeightMap;
|
import org.bukkit.HeightMap;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.Material;
|
||||||
import org.bukkit.World;
|
import org.bukkit.World;
|
||||||
import org.bukkit.block.Block;
|
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.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.assertEquals;
|
||||||
import static org.junit.Assert.assertNotNull;
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
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.doReturn;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
public class WorldRuntimeControlServiceSafeEntryTest {
|
public class WorldRuntimeControlServiceSafeEntryTest {
|
||||||
|
private static final BoundingBox FULL_BLOCK = new BoundingBox(0D, 0D, 0D, 1D, 1D, 1D);
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void resolvesStudioEntryAnchorFromGeneratorInsteadOfMutableWorldSpawn() {
|
public void resolvesStudioEntryAnchorFromGeneratorInsteadOfMutableWorldSpawn() {
|
||||||
World world = mock(World.class);
|
World world = mock(World.class);
|
||||||
@@ -47,21 +60,180 @@ public class WorldRuntimeControlServiceSafeEntryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void resolvesSafeEntryImmediatelyWhenColumnIsAllWater() {
|
public void resolvesEntryAboveDryCollisionSupportingFloor() {
|
||||||
World world = mock(World.class);
|
World world = loadedWorld(0, 0);
|
||||||
Block stub = mock(Block.class, Mockito.RETURNS_DEEP_STUBS);
|
Block stone = block(Material.STONE, false, false, FULL_BLOCK);
|
||||||
doReturn(-64).when(world).getMinHeight();
|
Block air = block(Material.AIR, false, true);
|
||||||
doReturn(320).when(world).getMaxHeight();
|
doReturn(62).when(world).getHighestBlockYAt(anyInt(), anyInt(), eq(HeightMap.MOTION_BLOCKING_NO_LEAVES));
|
||||||
doReturn(true).when(world).isChunkLoaded(0, 0);
|
doAnswer(invocation -> {
|
||||||
doReturn(62).when(world).getHighestBlockYAt(0, 0);
|
int y = invocation.getArgument(1);
|
||||||
doReturn(62).when(world).getHighestBlockYAt(0, 0, HeightMap.MOTION_BLOCKING_NO_LEAVES);
|
return y == 62 ? stone : air;
|
||||||
doReturn(stub).when(world).getBlockAt(anyInt(), anyInt(), anyInt());
|
}).when(world).getBlockAt(anyInt(), anyInt(), anyInt());
|
||||||
|
|
||||||
Location source = new Location(world, 0.5D, 62D, 0.5D);
|
Location source = new Location(world, 0.5D, 62D, 0.5D);
|
||||||
Location result = WorldRuntimeControlService.findTopSafeLocation(world, source);
|
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());
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+188
@@ -14,13 +14,25 @@ import org.junit.BeforeClass;
|
|||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.mockito.invocation.InvocationOnMock;
|
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.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.logging.Logger;
|
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.assertSame;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
import static org.mockito.Answers.CALLS_REAL_METHODS;
|
import static org.mockito.Answers.CALLS_REAL_METHODS;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
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() {
|
private Fixture createFixture() {
|
||||||
IrisBiome rootLowBiome = mock(IrisBiome.class);
|
IrisBiome rootLowBiome = mock(IrisBiome.class);
|
||||||
IrisBiome rootHighBiome = mock(IrisBiome.class);
|
IrisBiome rootHighBiome = mock(IrisBiome.class);
|
||||||
@@ -454,6 +633,15 @@ public class IrisDimensionCarvingResolverParityTest {
|
|||||||
private record Fixture(Engine engine) {
|
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 static final class LegacyCarvingChoice implements IRare {
|
||||||
private final IrisDimensionCarvingEntry entry;
|
private final IrisDimensionCarvingEntry entry;
|
||||||
private final int rarity;
|
private final int rarity;
|
||||||
|
|||||||
+3
-3
@@ -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
|
# Bukkit plugin api-version: lowest supported Minecraft release line so one artifact loads on 26.1.2 and 26.2
|
||||||
apiVersion=26.1
|
apiVersion=26.1
|
||||||
fabricLoaderVersion=0.19.3
|
fabricLoaderVersion=0.19.3
|
||||||
forgeVersion=26.2-65.0.4
|
forgeVersion=26.2-65.1.1
|
||||||
neoForgeVersion=26.2.0.12-beta
|
neoForgeVersion=26.2.0.59
|
||||||
volmLibCoordinate=com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969
|
volmLibCoordinate=com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
|
||||||
.orElse('com.github.VolmitSoftware:VolmLib:d9026a7c8ebc391c8109f401ce79a0ce65df3969')
|
.orElse('com.github.VolmitSoftware:VolmLib:5486ac97ad6d6833e27275dc768222d377721522')
|
||||||
.get()
|
.get()
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|||||||
Reference in New Issue
Block a user