This commit is contained in:
Brian Neumann-Fopiano
2026-08-13 12:38:08 -04:00
parent 14b6280668
commit 1586b4bd1a
68 changed files with 733 additions and 211 deletions
@@ -57,6 +57,7 @@ public class CustomBiomeSource extends BiomeSource {
private volatile KMap<String, Holder<Biome>> customBiomes; private volatile KMap<String, Holder<Biome>> customBiomes;
private volatile Map<Biome, Holder<Biome>> vanillaSpawnBiomes; private volatile Map<Biome, Holder<Biome>> vanillaSpawnBiomes;
private volatile IrisDimension cacheDimension; private volatile IrisDimension cacheDimension;
private volatile int cacheRuntimeId;
public CustomBiomeSource(long seed, Engine engine, World world) { public CustomBiomeSource(long seed, Engine engine, World world) {
this.engine = engine; this.engine = engine;
@@ -67,6 +68,7 @@ public class CustomBiomeSource extends BiomeSource {
this.customBiomes = fillCustomBiomes(this.biomeCustomRegistry, engine, this.fallbackBiome); this.customBiomes = fillCustomBiomes(this.biomeCustomRegistry, engine, this.fallbackBiome);
this.vanillaSpawnBiomes = fillVanillaSpawnBiomes(this.biomeCustomRegistry, this.biomeRegistry, engine); this.vanillaSpawnBiomes = fillVanillaSpawnBiomes(this.biomeCustomRegistry, this.biomeRegistry, engine);
this.cacheDimension = engine.getDimension(); this.cacheDimension = engine.getDimension();
this.cacheRuntimeId = engine.getCacheID();
} }
private static List<Holder<Biome>> getAllBiomes(Registry<Biome> customRegistry, Registry<Biome> registry, Engine engine) { private static List<Holder<Biome>> getAllBiomes(Registry<Biome> customRegistry, Registry<Biome> registry, Engine engine) {
@@ -246,11 +248,11 @@ public class CustomBiomeSource extends BiomeSource {
} }
GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_spawn_biome"); GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_spawn_biome");
if (lease == null) { if (lease == null) {
throw new IllegalStateException("Iris spawn biome lookup was rejected during an engine transition"); return null;
} }
try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
if (!isRuntimeAvailable()) { if (!isRuntimeAvailable()) {
throw new IllegalStateException("Iris spawn biome lookup has no active engine runtime"); return null;
} }
ensureCachesCurrent(); ensureCachesCurrent();
return vanillaSpawnBiomes.get(biome.value()); return vanillaSpawnBiomes.get(biome.value());
@@ -434,11 +436,12 @@ public class CustomBiomeSource extends BiomeSource {
private void ensureCachesCurrent() { private void ensureCachesCurrent() {
IrisDimension dimension = engine.getDimension(); IrisDimension dimension = engine.getDimension();
if (cacheDimension == dimension) { int runtimeId = engine.getCacheID();
if (cacheDimension == dimension && cacheRuntimeId == runtimeId) {
return; return;
} }
synchronized (this) { synchronized (this) {
if (cacheDimension == dimension) { if (cacheDimension == dimension && cacheRuntimeId == runtimeId) {
return; return;
} }
KMap<String, Holder<Biome>> refreshedCustomBiomes = fillCustomBiomes( KMap<String, Holder<Biome>> refreshedCustomBiomes = fillCustomBiomes(
@@ -451,6 +454,7 @@ public class CustomBiomeSource extends BiomeSource {
customBiomes = refreshedCustomBiomes; customBiomes = refreshedCustomBiomes;
vanillaSpawnBiomes = refreshedSpawnBiomes; vanillaSpawnBiomes = refreshedSpawnBiomes;
cacheDimension = dimension; cacheDimension = dimension;
cacheRuntimeId = runtimeId;
} }
} }
@@ -57,6 +57,7 @@ final class ImportedFeatureStage {
private final Engine engine; private final Engine engine;
private volatile FeatureTable featureTable; private volatile FeatureTable featureTable;
private volatile IrisDimension inertDimension; private volatile IrisDimension inertDimension;
private volatile int settledRuntimeId;
ImportedFeatureStage(Engine engine) { ImportedFeatureStage(Engine engine) {
this.engine = engine; this.engine = engine;
@@ -86,18 +87,23 @@ final class ImportedFeatureStage {
*/ */
void prepare(WorldGenLevel level) { void prepare(WorldGenLevel level) {
IrisDimension dimension = engine.getDimension(); IrisDimension dimension = engine.getDimension();
if (settled(dimension)) { int runtimeId = engine.getCacheID();
if (settled(dimension, runtimeId)) {
return; return;
} }
synchronized (this) { synchronized (this) {
if (settled(dimension)) { if (settled(dimension, runtimeId)) {
return; return;
} }
build(level, dimension); build(level, dimension);
settledRuntimeId = runtimeId;
} }
} }
private boolean settled(IrisDimension dimension) { private boolean settled(IrisDimension dimension, int runtimeId) {
if (settledRuntimeId != runtimeId) {
return false;
}
FeatureTable current = featureTable; FeatureTable current = featureTable;
if (current != null && current.dimension() == dimension) { if (current != null && current.dimension() == dimension) {
return true; return true;
@@ -128,6 +128,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
private final int runtimeHeight; private final int runtimeHeight;
private final int runtimeSeaLevel; private final int runtimeSeaLevel;
private final ConcurrentHashMap<SpawnTableKey, WeightedList<MobSpawnSettings.SpawnerData>> mergedSpawnTables = new ConcurrentHashMap<>(); private final ConcurrentHashMap<SpawnTableKey, WeightedList<MobSpawnSettings.SpawnerData>> mergedSpawnTables = new ConcurrentHashMap<>();
private volatile IrisDimension spawnTableDimension;
private volatile int spawnTableRuntimeId;
private final ImportedFeatureStage importedFeatures; private final ImportedFeatureStage importedFeatures;
private final AtomicReference<StudioStructureState> retainedStudioStructureState = new AtomicReference<>(); private final AtomicReference<StudioStructureState> retainedStudioStructureState = new AtomicReference<>();
private volatile ReachableStructureCache reachableStructureCache; private volatile ReachableStructureCache reachableStructureCache;
@@ -310,18 +312,19 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
private Set<String> reachableStructureKeys(ServerLevel level) { private Set<String> reachableStructureKeys(ServerLevel level) {
IrisDimension dimension = engine.getDimension(); IrisDimension dimension = engine.getDimension();
int runtimeId = engine.getCacheID();
ReachableStructureCache cached = reachableStructureCache; ReachableStructureCache cached = reachableStructureCache;
if (cached != null && cached.dimension() == dimension) { if (cached != null && cached.dimension() == dimension && cached.runtimeId() == runtimeId) {
return cached.keys(); return cached.keys();
} }
synchronized (this) { synchronized (this) {
cached = reachableStructureCache; cached = reachableStructureCache;
if (cached != null && cached.dimension() == dimension) { if (cached != null && cached.dimension() == dimension && cached.runtimeId() == runtimeId) {
return cached.keys(); return cached.keys();
} }
Set<String> reachable = Set.copyOf( Set<String> reachable = Set.copyOf(
VanillaStructureBiomes.reachableStructureKeys(level, customBiomeSource)); VanillaStructureBiomes.reachableStructureKeys(level, customBiomeSource));
reachableStructureCache = new ReachableStructureCache(dimension, reachable); reachableStructureCache = new ReachableStructureCache(dimension, runtimeId, reachable);
return reachable; return reachable;
} }
} }
@@ -738,6 +741,17 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
return explicitSpawns; return explicitSpawns;
} }
IrisDimension spawnDimension = engine.getDimension();
int spawnRuntimeId = engine.getCacheID();
if (spawnTableDimension != spawnDimension || spawnTableRuntimeId != spawnRuntimeId) {
synchronized (mergedSpawnTables) {
if (spawnTableDimension != spawnDimension || spawnTableRuntimeId != spawnRuntimeId) {
mergedSpawnTables.clear();
spawnTableDimension = spawnDimension;
spawnTableRuntimeId = spawnRuntimeId;
}
}
}
SpawnTableKey key = new SpawnTableKey(holder.value(), enumcreaturetype); SpawnTableKey key = new SpawnTableKey(holder.value(), enumcreaturetype);
return mergedSpawnTables.computeIfAbsent(key, ignored -> mergeSpawnTables(vanillaSpawns, explicitSpawns)); return mergedSpawnTables.computeIfAbsent(key, ignored -> mergeSpawnTables(vanillaSpawns, explicitSpawns));
} }
@@ -1160,7 +1174,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
private record SpawnTableKey(Biome biome, MobCategory category) { private record SpawnTableKey(Biome biome, MobCategory category) {
} }
private record ReachableStructureCache(IrisDimension dimension, Set<String> keys) { private record ReachableStructureCache(IrisDimension dimension, int runtimeId, Set<String> keys) {
} }
private record StructureStepCache(Registry<Structure> registry, List<List<Structure>> structures) { private record StructureStepCache(Registry<Structure> registry, List<List<Structure>> structures) {
@@ -26,8 +26,9 @@ public class CustomBiomeSourceStructureContractTest {
assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_possible_biomes\")")); assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_possible_biomes\")"));
assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_spawn_biome\")")); assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_spawn_biome\")"));
assertTrue(source.contains("Iris spawn biome lookup was rejected during an engine transition")); assertFalse(source.contains("Iris spawn biome lookup was rejected during an engine transition"));
assertTrue(source.contains("Iris spawn biome lookup has no active engine runtime")); assertFalse(source.contains("Iris spawn biome lookup has no active engine runtime"));
assertTrue(source.contains("vanillaSpawnBiomes.get(biome.value())"));
assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_structure_biome\")")); assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_structure_biome\")"));
assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_biomes_within\")")); assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_biomes_within\")"));
assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_visible_biome\")")); assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_visible_biome\")"));
@@ -613,18 +613,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
// (listeners, shutdown hook, replacement journals) are the safety-critical ones. // (listeners, shutdown hook, replacement journals) are the safety-critical ones.
// Only services that actually enabled get listeners and a later onDisable. // Only services that actually enabled get listeners and a later onDisable.
enabledServices.clear(); enabledServices.clear();
IrisService firstFailedService = null;
for (IrisService service : orderedServices) { for (IrisService service : orderedServices) {
try { try {
service.onEnable(); service.onEnable();
enabledServices.add(service); enabledServices.add(service);
} catch (Throwable e) { } catch (Throwable e) {
if (firstFailedService == null) { // A service failure is NOT a datapack validation failure: the admission gate
firstFailedService = service; // must never lock every login over a broken cosmetic service. Log loudly,
} // continue degraded, and clean up whatever the partial onEnable started
// (a failed service is excluded from the teardown loop).
Iris.reportError("Failed to enable " + service.getClass().getSimpleName() + "; continuing with a degraded runtime.", e); Iris.reportError("Failed to enable " + service.getClass().getSimpleName() + "; continuing with a degraded runtime.", e);
// A failed service is excluded from the teardown loop, so clean up whatever
// its partial onEnable started right here, best-effort.
try { try {
service.onDisable(); service.onDisable();
} catch (Throwable cleanup) { } catch (Throwable cleanup) {
@@ -632,11 +630,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
} }
} }
if (firstFailedService != null) {
IrisStartupValidation.markDatapacksInvalid("Iris service "
+ firstFailedService.getClass().getSimpleName()
+ " failed to enable; world creation and player admission are locked. Check the log above.");
}
for (IrisService service : enabledServices) { for (IrisService service : enabledServices) {
try { try {
registerListener(service); registerListener(service);
@@ -468,7 +468,21 @@ public final class PendingWorldReplacementManager implements Listener {
} }
private List<Transaction> loadTransactions() throws IOException { private List<Transaction> loadTransactions() throws IOException {
return WorldReplacementJournal.load(dataDirectory(), IrisWorldStorage.levelRoot().toPath()); Path levelRoot = IrisWorldStorage.levelRoot().toPath();
List<Transaction> transactions = WorldReplacementJournal.load(dataDirectory(), levelRoot);
List<Transaction> applicable = new ArrayList<>(transactions.size());
for (Transaction transaction : transactions) {
// A journal staged against another level root is not this server's transaction;
// skip it (the bootstrap already told the operator how to resolve it).
if (WorldReplacementJournal.appliesTo(transaction, levelRoot)) {
applicable.add(transaction);
} else {
Iris.warn("Ignoring pending world replacement " + transaction.id() + " for "
+ transaction.worldKey() + "; it was staged against level root "
+ transaction.levelRoot() + ".");
}
}
return applicable;
} }
private void writeTransaction(Transaction transaction) throws IOException { private void writeTransaction(Transaction transaction) throws IOException {
@@ -263,6 +263,10 @@ public class CommandPack implements DirectorExecutor {
for (File packDirectory : packDirectories) { for (File packDirectory : packDirectories) {
PackValidationResult result = PackValidationRegistry.get(packDirectory.getName()); PackValidationResult result = PackValidationRegistry.get(packDirectory.getName());
if (result == null) { if (result == null) {
// The boot cache only loads when it covers every pack, so a partial write is
// pointless - but say so instead of silently skipping the persist forever.
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
+ "\" has no registered result. Run /iris pack validate all to repopulate it.");
return; return;
} }
results.add(result); results.add(result);
@@ -329,8 +329,7 @@ public class CommandStudio implements DirectorExecutor {
return; return;
} }
if (radius <= 0 || radius > 2048) { if (radius <= 0 || radius > 2048) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_REGIONS_RADIUS_OUT_OF_RANGE));
sender().sendMessage("Radius must be between 1 and 2048 chunks.");
return; return;
} }
var sender = sender(); var sender = sender();
@@ -393,7 +392,8 @@ public class CommandStudio implements DirectorExecutor {
data.forEach((k, v) -> sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_MESSAGE, MessageArgument.untrusted("k", k), MessageArgument.untrusted("value", loader.load(k).getRarity()), MessageArgument.untrusted("value2", Form.f((double) v.get() / totalTasks * 100, 2))))); data.forEach((k, v) -> sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_MESSAGE, MessageArgument.untrusted("k", k), MessageArgument.untrusted("value", loader.load(k).getRarity()), MessageArgument.untrusted("value2", Form.f((double) v.get() / totalTasks * 100, 2)))));
} catch (Throwable e) { } catch (Throwable e) {
Iris.reportError(e); Iris.reportError(e);
sender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD)); sender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_REGIONS_SCAN_FAILED,
MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
} finally { } finally {
if (c != -1) { if (c != -1) {
J.car(c); J.car(c);
@@ -712,7 +712,7 @@ public class CommandStudio implements DirectorExecutor {
} }
} }
@Director(aliases = "find-objects", description = "Capture an IGenData chunk report for nearby chunks", descriptionKey = "iris.director.commandstudio.director.capture_igendata_chunk_report_nearby_chunks") @Director(aliases = "find-objects", description = "Capture an IGenData chunk report for nearby chunks", descriptionKey = "iris.director.commandstudio.director.capture_igendata_chunk_report_nearby_chunks", origin = DirectorOrigin.PLAYER)
public void objects() { public void objects() {
if (!IrisToolbelt.isIrisWorld(player().getWorld())) { if (!IrisToolbelt.isIrisWorld(player().getWorld())) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_MUST_BE_IRIS_WORLD)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_MUST_BE_IRIS_WORLD));
@@ -0,0 +1,31 @@
package art.arcane.iris.nativegen;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
/**
* Shared waiter for the racy build caches in this package: the loser of a putIfAbsent race
* must surface the same exception the builder threw, not a CompletionException wrapper.
*/
final class NativeBuildFutures {
private NativeBuildFutures() {
}
static <T> T awaitBuild(CompletableFuture<T> future, String what) {
try {
return future.join();
} catch (CompletionException error) {
Throwable cause = error.getCause();
if (cause instanceof RuntimeException runtime) {
throw runtime;
}
if (cause instanceof Error fatal) {
throw fatal;
}
if (cause == null) {
throw error;
}
throw new IllegalStateException(what + " failed", cause);
}
}
}
@@ -39,11 +39,13 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp
import java.util.ArrayList; import java.util.ArrayList;
import java.util.BitSet; import java.util.BitSet;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.WeakHashMap;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -73,8 +75,11 @@ public final class NativeStructureTerrainIntegrator {
Blocks.SOUL_SOIL, Blocks.END_STONE, Blocks.GRAVEL, Blocks.CLAY, Blocks.SOUL_SOIL, Blocks.END_STONE, Blocks.GRAVEL, Blocks.CLAY,
Blocks.CALCITE, Blocks.DRIPSTONE_BLOCK, Blocks.SANDSTONE, Blocks.CALCITE, Blocks.DRIPSTONE_BLOCK, Blocks.SANDSTONE,
Blocks.RED_SANDSTONE, Blocks.SCULK); Blocks.RED_SANDSTONE, Blocks.SCULK);
private static final Map<CarveFootprintKey, CachedCarveFootprint> CARVE_FOOTPRINTS = // Weakly keyed on the StructureStart so retired starts (world unload, generation done)
new LinkedHashMap<>(16, 0.75F, true); // are collectable instead of pinned forever by a process-wide static; values hold only
// primitives, so there is no value-to-key cycle defeating the weak keys.
private static final Map<StructureStart, Map<Integer, CachedCarveFootprint>> CARVE_FOOTPRINTS =
new WeakHashMap<>(16);
private static final ConcurrentHashMap<CarveFootprintKey, CompletableFuture<StructureCarvingFootprint>> private static final ConcurrentHashMap<CarveFootprintKey, CompletableFuture<StructureCarvingFootprint>>
CARVE_FOOTPRINT_BUILDS = new ConcurrentHashMap<>(); CARVE_FOOTPRINT_BUILDS = new ConcurrentHashMap<>();
private static int cachedCarveCells; private static int cachedCarveCells;
@@ -531,6 +536,15 @@ public final class NativeStructureTerrainIntegrator {
if (active != null) { if (active != null) {
return awaitCarveFootprint(active); return awaitCarveFootprint(active);
} }
// Close the check-then-claim window: a racer whose cache check missed before the
// previous builder cached can claim the build slot after that builder retired it,
// and would rebuild a second instance without this re-check.
StructureCarvingFootprint published = cachedCarveFootprint(key);
if (published != null) {
build.complete(published);
CARVE_FOOTPRINT_BUILDS.remove(key, build);
return published;
}
try { try {
StructureCarvingFootprint footprint = StructureCarvingFootprint.fromColumns( StructureCarvingFootprint footprint = StructureCarvingFootprint.fromColumns(
sink -> emitCarveColumns(start, templates, sink), horizontalPadding, MAX_CARVE_COLUMNS); sink -> emitCarveColumns(start, templates, sink), horizontalPadding, MAX_CARVE_COLUMNS);
@@ -551,53 +565,64 @@ public final class NativeStructureTerrainIntegrator {
static int cachedCarveFootprintCells() { static int cachedCarveFootprintCells() {
synchronized (CARVE_FOOTPRINTS) { synchronized (CARVE_FOOTPRINTS) {
cachedCarveCells = liveCarveCells();
return cachedCarveCells; return cachedCarveCells;
} }
} }
private static int liveCarveCells() {
int total = 0;
for (Map<Integer, CachedCarveFootprint> perStart : CARVE_FOOTPRINTS.values()) {
for (CachedCarveFootprint cached : perStart.values()) {
total += cached.cells();
}
}
return total;
}
static int maximumCachedCarveFootprintCells() { static int maximumCachedCarveFootprintCells() {
return MAX_CACHED_CARVE_CELLS; return MAX_CACHED_CARVE_CELLS;
} }
private static StructureCarvingFootprint cachedCarveFootprint(CarveFootprintKey key) { private static StructureCarvingFootprint cachedCarveFootprint(CarveFootprintKey key) {
synchronized (CARVE_FOOTPRINTS) { synchronized (CARVE_FOOTPRINTS) {
CachedCarveFootprint cached = CARVE_FOOTPRINTS.get(key); Map<Integer, CachedCarveFootprint> perStart = CARVE_FOOTPRINTS.get(key.start());
CachedCarveFootprint cached = perStart == null ? null : perStart.get(key.padding());
return cached == null ? null : cached.footprint(); return cached == null ? null : cached.footprint();
} }
} }
private static StructureCarvingFootprint awaitCarveFootprint( private static StructureCarvingFootprint awaitCarveFootprint(
CompletableFuture<StructureCarvingFootprint> future) { CompletableFuture<StructureCarvingFootprint> future) {
try { return NativeBuildFutures.awaitBuild(future, "Native structure carve footprint build");
return future.join();
} catch (CompletionException error) {
Throwable cause = error.getCause();
if (cause instanceof RuntimeException runtime) {
throw runtime;
}
if (cause instanceof Error fatal) {
throw fatal;
}
throw new IllegalStateException("Native structure carve footprint build failed", cause);
}
} }
private static void cacheCarveFootprint(CarveFootprintKey key, private static void cacheCarveFootprint(CarveFootprintKey key,
StructureCarvingFootprint footprint) { StructureCarvingFootprint footprint) {
int cells = Math.multiplyExact(footprint.width(), footprint.depth()); int cells = Math.multiplyExact(footprint.width(), footprint.depth());
synchronized (CARVE_FOOTPRINTS) { synchronized (CARVE_FOOTPRINTS) {
CachedCarveFootprint previous = CARVE_FOOTPRINTS.remove(key); // Weak keys expunge silently as the GC drops retired starts; re-derive the live
// total so the cell budget tracks reality instead of drifting.
cachedCarveCells = liveCarveCells();
Map<Integer, CachedCarveFootprint> perStart =
CARVE_FOOTPRINTS.computeIfAbsent(key.start(), ignored -> new LinkedHashMap<>(4));
CachedCarveFootprint previous = perStart.remove(key.padding());
if (previous != null) { if (previous != null) {
cachedCarveCells -= previous.cells(); cachedCarveCells -= previous.cells();
} }
while (!CARVE_FOOTPRINTS.isEmpty() Iterator<Map.Entry<StructureStart, Map<Integer, CachedCarveFootprint>>> eviction =
&& cachedCarveCells + cells > MAX_CACHED_CARVE_CELLS) { CARVE_FOOTPRINTS.entrySet().iterator();
Map.Entry<CarveFootprintKey, CachedCarveFootprint> eldest = while (cachedCarveCells + cells > MAX_CACHED_CARVE_CELLS && eviction.hasNext()) {
CARVE_FOOTPRINTS.entrySet().iterator().next(); Map.Entry<StructureStart, Map<Integer, CachedCarveFootprint>> victim = eviction.next();
cachedCarveCells -= eldest.getValue().cells(); if (victim.getKey() == key.start()) {
CARVE_FOOTPRINTS.remove(eldest.getKey()); continue;
}
for (CachedCarveFootprint evicted : victim.getValue().values()) {
cachedCarveCells -= evicted.cells();
}
eviction.remove();
} }
CARVE_FOOTPRINTS.put(key, new CachedCarveFootprint(footprint, cells)); perStart.put(key.padding(), new CachedCarveFootprint(footprint, cells));
cachedCarveCells += cells; cachedCarveCells += cells;
} }
} }
@@ -329,7 +329,18 @@ public final class NativeStructureVolumeIndex {
CompletableFuture<KList<NativeStructureVolume>> future = new CompletableFuture<>(); CompletableFuture<KList<NativeStructureVolume>> future = new CompletableFuture<>();
CompletableFuture<KList<NativeStructureVolume>> existing = builds.putIfAbsent(key, future); CompletableFuture<KList<NativeStructureVolume>> existing = builds.putIfAbsent(key, future);
if (existing != null) { if (existing != null) {
return existing.join(); return NativeBuildFutures.awaitBuild(existing, "Native structure volume build");
}
// Close the check-then-claim window: a racer whose cache check missed before the
// previous builder cached could claim the build slot after it retired and rebuild.
synchronized (cache) {
KList<NativeStructureVolume> published = cache.get(key);
if (published != null) {
future.complete(published);
builds.remove(key, future);
return published;
}
} }
try { try {
@@ -236,6 +236,19 @@ public final class ModdedPlatform implements IrisPlatform {
*/ */
@Override @Override
public void reportError(Throwable error) { public void reportError(Throwable error) {
reportThrottled("Iris reported error", error);
}
/**
* Contextual reports run the same throttle and emit exactly one trace through the modded
* log; the SPI default's raw stderr copy would bypass the per-signature suppression.
*/
@Override
public void reportError(String context, Throwable error) {
reportThrottled(context == null || context.isBlank() ? "Iris reported error" : context, error);
}
private void reportThrottled(String message, Throwable error) {
if (error == null) { if (error == null) {
return; return;
} }
@@ -247,7 +260,7 @@ public final class ModdedPlatform implements IrisPlatform {
sink.accept(error); sink.accept(error);
return; return;
} }
ModdedIrisLog.error("Iris reported error", error); ModdedIrisLog.error(message, error);
Consumer<Throwable> capture = CAPTURE_SINK; Consumer<Throwable> capture = CAPTURE_SINK;
if (capture != null) { if (capture != null) {
try { try {
@@ -800,6 +800,18 @@ public final class ModdedWorldManager implements EngineWorldManager {
} }
if (!mantleWarmupExecutorStopped) { if (!mantleWarmupExecutorStopped) {
try { try {
// Drain, don't interrupt: an interrupt inside a FileChannel plate read closes
// the channel and the read-failure fallback installs an empty plate that a
// later flush persists over real data. Queued tasks no-op on the closed flag,
// so the await only covers a single in-flight load; escalate on timeout only.
mantleWarmupExecutor.shutdown();
if (!mantleWarmupExecutor.awaitTermination(5L, java.util.concurrent.TimeUnit.SECONDS)) {
IrisLogging.warn("Iris mantle warm-up did not stop before world manager close; forcing interrupt");
mantleWarmupExecutor.shutdownNow();
}
mantleWarmupExecutorStopped = true;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
mantleWarmupExecutor.shutdownNow(); mantleWarmupExecutor.shutdownNow();
mantleWarmupExecutorStopped = true; mantleWarmupExecutorStopped = true;
} catch (Throwable e) { } catch (Throwable e) {
@@ -20,6 +20,7 @@ package art.arcane.iris.modded.api;
import art.arcane.iris.core.tools.WorldMaintenance; import art.arcane.iris.core.tools.WorldMaintenance;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineLifecycleTasks;
import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.command.ModdedPregenJob; import art.arcane.iris.modded.command.ModdedPregenJob;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
@@ -130,14 +131,17 @@ public final class IrisModdedAPI {
* never create or load one - or nothing of {@code type} is stored there. A {@code y} outside the engine's * never create or load one - or nothing of {@code type} is stored there. A {@code y} outside the engine's
* height range reads as null rather than throwing. * height range reads as null rather than throwing.
* *
* @throws IllegalStateException if the engine's mantle has already been closed * A closing or closed engine refuses the operation quietly (null / no-op) instead of racing its shutdown.
*/ */
public static <T> T getMantleData(ServerLevel level, int x, int y, int z, Class<T> type) { public static <T> T getMantleData(ServerLevel level, int x, int y, int z, Class<T> type) {
Engine engine = getEngine(level); Engine engine = getEngine(level);
if (engine == null) { if (engine == null) {
return null; return null;
} }
return engine.getMantle().getMantle().get(x, y - engine.getMinHeight(), z, type); // Lease-gated so the engine shutdown drain covers this public accessor; a mantle
// mid-close refuses the lease instead of racing the region flush.
return EngineLifecycleTasks.call(engine, "modded_api_mantle_get",
() -> engine.getMantle().getMantle().get(x, y - engine.getMinHeight(), z, type), null);
} }
/** /**
@@ -151,28 +155,30 @@ public final class IrisModdedAPI {
* Values written under a custom type are discarded when Iris trims a mantle region unless the type is * Values written under a custom type are discarded when Iris trims a mantle region unless the type is
* declared with {@link #retainMantleDataForSlice(Class)}. * declared with {@link #retainMantleDataForSlice(Class)}.
* *
* @throws IllegalStateException if the engine's mantle has already been closed * A closing or closed engine refuses the operation quietly (null / no-op) instead of racing its shutdown.
*/ */
public static <T> void setMantleData(ServerLevel level, int x, int y, int z, T data) { public static <T> void setMantleData(ServerLevel level, int x, int y, int z, T data) {
Engine engine = getEngine(level); Engine engine = getEngine(level);
if (engine == null || data == null) { if (engine == null || data == null) {
return; return;
} }
engine.getMantle().getMantle().set(x, y - engine.getMinHeight(), z, data); EngineLifecycleTasks.run(engine, "modded_api_mantle_set",
() -> engine.getMantle().getMantle().set(x, y - engine.getMinHeight(), z, data));
} }
/** /**
* Removes any mantle value of {@code type} at world coordinates. A non-Iris level or an out-of-range * Removes any mantle value of {@code type} at world coordinates. A non-Iris level or an out-of-range
* {@code y} is a silent no-op. Like a write, this creates the mantle region if it is absent. * {@code y} is a silent no-op. Like a write, this creates the mantle region if it is absent.
* *
* @throws IllegalStateException if the engine's mantle has already been closed * A closing or closed engine refuses the operation quietly (null / no-op) instead of racing its shutdown.
*/ */
public static <T> void deleteMantleData(ServerLevel level, int x, int y, int z, Class<T> type) { public static <T> void deleteMantleData(ServerLevel level, int x, int y, int z, Class<T> type) {
Engine engine = getEngine(level); Engine engine = getEngine(level);
if (engine == null) { if (engine == null) {
return; return;
} }
engine.getMantle().getMantle().remove(x, y - engine.getMinHeight(), z, type); EngineLifecycleTasks.run(engine, "modded_api_mantle_delete",
() -> engine.getMantle().getMantle().remove(x, y - engine.getMinHeight(), z, type));
} }
/** /**
@@ -69,6 +69,9 @@ final class ModdedCommandSuggestions {
private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
private static final int TAB_FAILURE_KEYS_MAX = 256; private static final int TAB_FAILURE_KEYS_MAX = 256;
private static final Set<String> REPORTED_TAB_FAILURES = ConcurrentHashMap.newKeySet(); private static final Set<String> REPORTED_TAB_FAILURES = ConcurrentHashMap.newKeySet();
private static final long PACK_NAME_CACHE_TTL_MS = 3_000L;
private static volatile Set<String> cachedPackNames;
private static volatile long cachedPackNamesAt;
private ModdedCommandSuggestions() { private ModdedCommandSuggestions() {
} }
@@ -228,6 +231,13 @@ final class ModdedCommandSuggestions {
private static CompletableFuture<Suggestions> suggestPackNames(CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) { private static CompletableFuture<Suggestions> suggestPackNames(CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) {
ModdedCommandFeedback.tab(context.getSource()); ModdedCommandFeedback.tab(context.getSource());
// Suggestion packets arrive per keystroke; a short-lived snapshot keeps the directory
// walk off the hot path without ever serving stale names for more than a few seconds.
long now = System.currentTimeMillis();
Set<String> cached = cachedPackNames;
if (cached != null && now - cachedPackNamesAt < PACK_NAME_CACHE_TTL_MS) {
return SharedSuggestionProvider.suggest(cached, builder);
}
Set<String> names = new TreeSet<>(); Set<String> names = new TreeSet<>();
names.add("overworld"); names.add("overworld");
try { try {
@@ -249,6 +259,8 @@ final class ModdedCommandSuggestions {
} catch (Throwable e) { } catch (Throwable e) {
warnTabFailure("pack names", context.getSource(), e); warnTabFailure("pack names", context.getSource(), e);
} }
cachedPackNames = names;
cachedPackNamesAt = now;
return SharedSuggestionProvider.suggest(names, builder); return SharedSuggestionProvider.suggest(names, builder);
} }
@@ -70,9 +70,10 @@ final class ModdedCommandTree {
.then(Commands.argument("dimension", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.DIMENSION_NAMES) .then(Commands.argument("dimension", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.DIMENSION_NAMES)
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), StringArgumentType.getString(context, "dimension"))))); .executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), StringArgumentType.getString(context, "dimension")))));
// ModdedWhatCommands.tree() gates itself at LEVEL_GAMEMASTERS; the /iris what overlays are read-only, // ModdedWhatCommands.tree() gates itself at LEVEL_GAMEMASTERS and keeps that gate:
// so the root builder relaxes the whole subtree here instead of forking that file. // "what markers" drives lease-gated 9x9 mantle scans, and the Bukkit twin puts the
root.then(ModdedWhatCommands.tree().requires(READ_ONLY)); // whole tree behind an op-default permission. requires() would overwrite, not AND.
root.then(ModdedWhatCommands.tree());
root.then(teleportTree("teleport")); root.then(teleportTree("teleport"));
root.then(teleportTree("tp")); root.then(teleportTree("tp"));
@@ -285,7 +285,20 @@ public final class ModdedObjectCommands {
return 0; return 0;
} }
File file = new File(engine.getData().getDataFolder(), "objects" + File.separator + name.replace('/', File.separatorChar) + ".iob"); File file = new File(engine.getData().getDataFolder(), "objects" + File.separator + name.replace('/', File.separatorChar) + ".iob");
if (file.exists() && !overwrite) { // Atomic path claim ON the server thread: the async write below turned a plain
// exists() check into a TOCTOU where two rapid saves interleaved into one file.
File parent = file.getParentFile();
if (parent != null) {
parent.mkdirs();
}
boolean claimed;
try {
claimed = file.createNewFile();
} catch (IOException e) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FAILED_SAVE_OBJECT, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
return 0;
}
if (!claimed && !overwrite) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FILE_ALREADY_EXISTS_USE_IRIS_OBJECT_SAVE_OVERWRITE, MessageArgument.untrusted("name", name))); IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FILE_ALREADY_EXISTS_USE_IRIS_OBJECT_SAVE_OVERWRITE, MessageArgument.untrusted("name", name)));
return 0; return 0;
} }
@@ -295,15 +308,16 @@ public final class ModdedObjectCommands {
// async-safe), but the disk write of a local, unshared object is not tick work. // async-safe), but the disk write of a local, unshared object is not tick work.
IrisObject object = capture(level, min, max, w, h, d, tilesSkipped, tilesSaved); IrisObject object = capture(level, min, max, w, h, d, tilesSkipped, tilesSaved);
MinecraftServer server = source.getServer(); MinecraftServer server = source.getServer();
boolean finalClaimed = claimed;
J.a(() -> { J.a(() -> {
File parent = file.getParentFile();
if (parent != null) {
parent.mkdirs();
}
try { try {
object.write(file); object.write(file);
} catch (IOException e) { } catch (IOException e) {
LOGGER.error("Iris object save failed for {}", file.getAbsolutePath(), e); LOGGER.error("Iris object save failed for {}", file.getAbsolutePath(), e);
if (finalClaimed) {
// Never leave a 0-byte claim file permanently blocking non-overwrite saves.
file.delete();
}
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FAILED_SAVE_OBJECT, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))))); server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FAILED_SAVE_OBJECT, MessageArgument.untrusted("value", String.valueOf(e.getMessage())))));
return; return;
} }
@@ -85,13 +85,16 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
ExecutorService active = warmupExecutor; ExecutorService active = warmupExecutor;
warmupExecutor = null; warmupExecutor = null;
if (active != null) { if (active != null) {
// Drop queued warm-ups (pure prefetch) and AWAIT: the very next shutdown stage // NEVER interrupt first: an interrupt inside a FileChannel plate read closes the
// closes every Mantle, and an in-flight mantle.getChunk would fault a plate back // channel (ClosedByInterruptException) and the read-failure fallback installs an
// in after the close-time flush. // EMPTY plate that the very next shutdown stage flushes over real data. Queued
active.shutdownNow(); // warm-ups self-cancel (warmupExecutor is already null), so shutdown() + await
// only waits on the single in-flight load; escalate only on timeout.
active.shutdown();
try { try {
if (!active.awaitTermination(5L, TimeUnit.SECONDS)) { if (!active.awaitTermination(5L, TimeUnit.SECONDS)) {
IrisLogging.warn("Iris mantle warm-up did not stop before engine close"); IrisLogging.warn("Iris mantle warm-up did not stop before engine close; forcing interrupt");
active.shutdownNow();
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
@@ -279,6 +282,12 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
} }
try { try {
active.execute(() -> { active.execute(() -> {
// Self-cancel on shutdown: warmupExecutor is nulled first in onDisable, so
// queued prefetches no-op instantly and only an in-flight load is awaited.
if (warmupExecutor == null || mantle.isClosed()) {
warmupQueue.remove(key);
return;
}
try { try {
mantle.getChunk(chunkX, chunkZ); mantle.getChunk(chunkX, chunkZ);
} catch (Throwable e) { } catch (Throwable e) {
@@ -492,7 +492,9 @@ public class NoiseExplorerGUI extends JPanel implements MouseWheelListener {
} }
long sleepMs = Math.max(1, 16 - (long) p.getMilliseconds()); long sleepMs = Math.max(1, 16 - (long) p.getMilliseconds());
EventQueue.invokeLater(() -> { // Pace on a worker, not the EDT: a sleep queued on the event thread blocks painting
// and input for the whole frame budget. repaint() marshals itself back.
J.a(() -> {
J.sleep(sleepMs); J.sleep(sleepMs);
repaint(); repaint();
}); });
@@ -124,7 +124,16 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
service.shutdown(); service.shutdown();
throw new IllegalStateException("An Iris pregeneration job is already running; stop it first."); throw new IllegalStateException("An Iris pregeneration job is already running; stop it first.");
} }
worker.start(); try {
worker.start();
} catch (Throwable startFailure) {
// Un-publish: a worker that never started can never run onClose(), so nothing
// else would ever clear this instance via the normal path.
instance.compareAndSet(this, null);
monitor.close();
service.shutdown();
throw startFailure;
}
} }
private void computeBounds() { private void computeBounds() {
@@ -528,14 +528,16 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
}); });
return; return;
} }
double frameMs = 0;
try { try {
paintBody(gx); frameMs = paintBody(gx);
} catch (Throwable e) { } catch (Throwable e) {
IrisLogging.debug("Vision paint failed: " + e.getClass().getSimpleName() + ": " + e.getMessage()); IrisLogging.debug("Vision paint failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
} finally { } finally {
// The repaint loop is entirely self-driven from here; it must survive any render // The repaint loop is entirely self-driven from here; it must survive any render
// exception or the window freezes on a stale frame permanently. // exception or the window freezes on a stale frame permanently. Frame-time
long sleepMs = eco ? 32 : 16; // compensated so a slow frame does not stack a full sleep on top of itself.
long sleepMs = Math.max(1, (eco ? 32 : 16) - (long) frameMs);
J.a(() -> { J.a(() -> {
J.sleep(sleepMs); J.sleep(sleepMs);
repaint(); repaint();
@@ -543,7 +545,7 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
} }
} }
private void paintBody(Graphics gx) { private double paintBody(Graphics gx) {
velocity = Math.abs(ox - oxp) * 0.36 + Math.abs(oz - ozp) * 0.36; velocity = Math.abs(ox - oxp) * 0.36 + Math.abs(oz - ozp) * 0.36;
oxp = lerp(oxp, ox, 0.36); oxp = lerp(oxp, ox, 0.36);
@@ -630,11 +632,7 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
handleFollow(); handleFollow();
renderOverlays(g, p.getMilliseconds()); renderOverlays(g, p.getMilliseconds());
return p.getMilliseconds();
if (!isVisible() || !getParent().isVisible()) {
return;
}
} }
private void renderGrid(Graphics2D g, int tileSize, double offsetX, double offsetZ) { private void renderGrid(Graphics2D g, int tileSize, double offsetX, double offsetZ) {
@@ -255,6 +255,13 @@ public final class BukkitWorldConfiguration {
} }
Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING); Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING);
} }
// bukkit.yml is the commit record the bootstrap reconciles against; its rename
// needs the same directory-durability barrier the journal and world moves get.
if (requireAtomicReplacement) {
DirectoryDurability.forceDirectoryRequired(parent);
} else {
DirectoryDurability.forceDirectoryAfterCommit(parent, "A bukkit.yml world configuration change");
}
} finally { } finally {
Files.deleteIfExists(staged); Files.deleteIfExists(staged);
} }
@@ -0,0 +1,42 @@
package art.arcane.iris.core.lifecycle;
import art.arcane.iris.spi.IrisLogging;
import java.io.File;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
/**
* Directory-level durability barrier shared by every atomic publication in the
* world-replacement protocol (journal writes, world-directory moves, bukkit.yml
* saves). A rename is only durable once its parent directory has been fsynced;
* skipped on Windows, where directory handles cannot be forced.
*/
final class DirectoryDurability {
private DirectoryDurability() {
}
static void forceDirectoryRequired(Path directory) throws IOException {
if (File.separatorChar == '\\') {
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
} catch (UnsupportedOperationException failure) {
throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure);
}
}
static void forceDirectoryAfterCommit(Path directory, String context) {
try {
forceDirectoryRequired(directory);
} catch (IOException failure) {
IrisLogging.reportError(
context + " completed, but its parent directory could not be durability-synced.",
failure
);
}
}
}
@@ -15,6 +15,7 @@ import org.bukkit.NamespacedKey;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.event.world.WorldUnloadEvent;
import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.ChunkGenerator;
import java.io.File; import java.io.File;
@@ -479,6 +480,10 @@ final class WorldLifecycleSupport {
} }
} }
if (!announceManualWorldUnload(world)) {
return CompletableFuture.completedFuture(false);
}
if (save) { if (save) {
world.save(); world.save();
} }
@@ -496,6 +501,12 @@ final class WorldLifecycleSupport {
} }
} }
static boolean announceManualWorldUnload(World world) {
WorldUnloadEvent unloadEvent = new WorldUnloadEvent(world);
Bukkit.getPluginManager().callEvent(unloadEvent);
return !unloadEvent.isCancelled();
}
private static CompletableFuture<Boolean> unloadWorldViaAsyncApi(CapabilitySnapshot capabilities, World world, boolean save) { private static CompletableFuture<Boolean> unloadWorldViaAsyncApi(CapabilitySnapshot capabilities, World world, boolean save) {
if (capabilities.unloadWorldAsyncMethod() == null || capabilities.bukkitServer() == null) { if (capabilities.unloadWorldAsyncMethod() == null || capabilities.bukkitServer() == null) {
return null; return null;
@@ -597,9 +608,11 @@ final class WorldLifecycleSupport {
Field worldsField = CapabilityResolution.resolveField(bukkitServer.getClass(), "worlds"); Field worldsField = CapabilityResolution.resolveField(bukkitServer.getClass(), "worlds");
Object rawWorlds = worldsField.get(bukkitServer); Object rawWorlds = worldsField.get(bukkitServer);
if (rawWorlds instanceof Map map) { if (rawWorlds instanceof Map map) {
map.remove(WorldIdentity.key(world)); boolean removed = map.values().removeIf(candidate -> candidate == world);
map.remove(WorldIdentity.serialize(world)); if (!removed) {
map.remove(world.getName()); throw new IllegalStateException(
"CraftServer world registry did not contain \"" + world.getName() + "\".");
}
} }
} }
@@ -38,7 +38,20 @@ public final class WorldReplacementBootstrap {
int published = 0; int published = 0;
int rolledBack = 0; int rolledBack = 0;
int retained = 0; int retained = 0;
int skipped = 0;
for (Transaction transaction : transactions) { for (Transaction transaction : transactions) {
// A journal recorded against another level root or logical world name is not this
// boot's transaction; skip it with a pointer instead of aborting the whole startup.
if (!WorldReplacementJournal.appliesTo(transaction, requiredLevelRoot)) {
skipped++;
requiredFeedback.accept("Skipping pending world replacement " + transaction.id()
+ " for " + transaction.worldKey() + ": it was staged against level root "
+ transaction.levelRoot() + " but this server now uses " + requiredLevelRoot
+ ". Remove " + requiredDataDirectory.resolve(WorldReplacementJournal.DIRECTORY_NAME)
.resolve(transaction.id() + ".properties")
+ " or restore the previous level-name to resolve it.");
continue;
}
ReconcileAction action = reconcileTransaction( ReconcileAction action = reconcileTransaction(
requiredDataDirectory, requiredDataDirectory,
requiredLevelRoot, requiredLevelRoot,
@@ -52,7 +65,7 @@ public final class WorldReplacementBootstrap {
case RETAINED -> retained++; case RETAINED -> retained++;
} }
} }
return new ReconcileResult(transactions.size(), published, rolledBack, retained); return new ReconcileResult(transactions.size(), published, rolledBack, retained, skipped);
} }
public static WorldGeneratorSnapshot replacementSnapshot(Transaction transaction) { public static WorldGeneratorSnapshot replacementSnapshot(Transaction transaction) {
@@ -124,11 +137,20 @@ 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.");
} }
WorldReplacementFilesystem.publish( try {
paths, WorldReplacementFilesystem.publish(
active.originalTargetPresent(), paths,
active.packFingerprint() active.originalTargetPresent(),
); active.packFingerprint()
);
} catch (IOException publishFailure) {
throw new IOException("Pending replacement for " + active.worldKey()
+ " cannot be published: " + publishFailure.getMessage()
+ " To recover, restore the original generator entry for \"" + active.worldName()
+ "\" in bukkit.yml (the next boot rolls the replacement back), or delete the journal at "
+ dataDirectory.resolve(WorldReplacementJournal.DIRECTORY_NAME).resolve(active.id() + ".properties")
+ ".", publishFailure);
}
active = active.withPhase(Phase.PUBLISHED); active = active.withPhase(Phase.PUBLISHED);
WorldReplacementJournal.write(dataDirectory, active); WorldReplacementJournal.write(dataDirectory, active);
feedback.accept("Published Iris world replacement for " + active.worldKey() feedback.accept("Published Iris world replacement for " + active.worldKey()
@@ -227,7 +249,7 @@ public final class WorldReplacementBootstrap {
return new IOException("Pending replacement for " + transaction.worldKey() + " is blocked: " + detail); return new IOException("Pending replacement for " + transaction.worldKey() + " is blocked: " + detail);
} }
public record ReconcileResult(int transactions, int published, int rolledBack, int retained) { public record ReconcileResult(int transactions, int published, int rolledBack, int retained, int skipped) {
} }
private enum ReconcileAction { private enum ReconcileAction {
@@ -2,9 +2,7 @@ package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.ExactWorldSlotPathPolicy; import art.arcane.iris.core.ExactWorldSlotPathPolicy;
import art.arcane.iris.core.SnapshotDirectoryTreeDeleter; import art.arcane.iris.core.SnapshotDirectoryTreeDeleter;
import art.arcane.iris.spi.IrisLogging;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
@@ -13,6 +11,7 @@ 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.LinkOption;
import java.nio.file.NoSuchFileException;
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;
@@ -61,6 +60,26 @@ public final class WorldReplacementFilesystem {
if (state.stagePresent() || state.backupPresent()) { if (state.stagePresent() || state.backupPresent()) {
throw new IOException("A replacement artifact already exists for this transaction."); throw new IOException("A replacement artifact already exists for this transaction.");
} }
requireMigratedRetainedWorld(requiredPaths.target());
}
public static void requireMigratedRetainedWorld(Path retainedWorld) throws IOException {
try {
requireDirectory(retainedWorld, "retained world");
requireDirectory(retainedWorld.resolve("data"), "retained world data");
requireDirectory(retainedWorld.resolve("data/paper"), "retained Paper data");
requireDirectory(retainedWorld.resolve("data/minecraft"), "retained Minecraft data");
for (Path relative : PAPER_WORLD_METADATA) {
BasicFileAttributes sourceAttributes = requireSafeEntry(retainedWorld.resolve(relative));
if (!sourceAttributes.isRegularFile()) {
throw new IOException("Retained Paper world metadata is not a regular file: " + relative);
}
}
} catch (NoSuchFileException e) {
throw new IOException("World slot " + retainedWorld.getFileName()
+ " is missing Paper world metadata (" + e.getFile()
+ "); load the world once on this server before replacing it.", e);
}
} }
public static void publish( public static void publish(
@@ -328,17 +347,8 @@ public final class WorldReplacementFilesystem {
} }
private static void preservePaperWorldMetadata(Path retainedWorld, Path replacementWorld) throws IOException { private static void preservePaperWorldMetadata(Path retainedWorld, Path replacementWorld) throws IOException {
requireDirectory(retainedWorld, "retained world"); requireMigratedRetainedWorld(retainedWorld);
requireDirectory(replacementWorld, "replacement world"); requireDirectory(replacementWorld, "replacement world");
requireDirectory(retainedWorld.resolve("data"), "retained world data");
requireDirectory(retainedWorld.resolve("data/paper"), "retained Paper data");
requireDirectory(retainedWorld.resolve("data/minecraft"), "retained Minecraft data");
for (Path relative : PAPER_WORLD_METADATA) {
BasicFileAttributes sourceAttributes = requireSafeEntry(retainedWorld.resolve(relative));
if (!sourceAttributes.isRegularFile()) {
throw new IOException("Retained Paper world metadata is not a regular file: " + relative);
}
}
ensureDirectory(replacementWorld.resolve("data"), "replacement world data"); ensureDirectory(replacementWorld.resolve("data"), "replacement world data");
ensureDirectory(replacementWorld.resolve("data/paper"), "replacement Paper data"); ensureDirectory(replacementWorld.resolve("data/paper"), "replacement Paper data");
ensureDirectory(replacementWorld.resolve("data/minecraft"), "replacement Minecraft data"); ensureDirectory(replacementWorld.resolve("data/minecraft"), "replacement Minecraft data");
@@ -420,25 +430,11 @@ public final class WorldReplacementFilesystem {
} }
private static void forceDirectoryRequired(Path directory) throws IOException { private static void forceDirectoryRequired(Path directory) throws IOException {
if (File.separatorChar == '\\') { DirectoryDurability.forceDirectoryRequired(directory);
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
} catch (UnsupportedOperationException failure) {
throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure);
}
} }
private static void forceDirectoryAfterCommit(Path directory) { private static void forceDirectoryAfterCommit(Path directory) {
try { DirectoryDurability.forceDirectoryAfterCommit(directory, "A world-replacement move");
forceDirectoryRequired(directory);
} catch (IOException failure) {
IrisLogging.reportError(
"A world-replacement move completed, but its parent directory could not be durability-synced.",
failure
);
}
} }
public record ReplacementPaths(Path target, Path stage, Path backup) { public record ReplacementPaths(Path target, Path stage, Path backup) {
@@ -3,10 +3,8 @@ 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.WorldSlotKey;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
import art.arcane.iris.spi.IrisLogging;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
@@ -46,19 +44,50 @@ public final class WorldReplacementJournal {
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Replacement journal entry is unsafe: " + file); throw new IOException("Replacement journal entry is unsafe: " + file);
} }
transactions.add(read(file, currentLevelRoot)); try {
transactions.add(read(file));
} catch (IOException failure) {
throw new IOException("Invalid replacement journal " + file + ": " + failure.getMessage(), failure);
}
} }
} }
transactions.sort(Comparator.comparing(transaction -> transaction.id().toString())); transactions.sort(Comparator.comparing(transaction -> transaction.id().toString()));
// Only transactions that target the current level root compete for a slot; a stale
// journal recorded against another level root must not collide with a live one.
Set<WorldSlotKey> worldKeys = new HashSet<>(); Set<WorldSlotKey> worldKeys = new HashSet<>();
for (Transaction transaction : transactions) { for (Transaction transaction : transactions) {
if (!worldKeys.add(transaction.worldKey())) { if (appliesTo(transaction, currentLevelRoot) && !worldKeys.add(transaction.worldKey())) {
throw new IOException("Multiple replacement journals target " + transaction.worldKey() + "."); throw new IOException("Multiple replacement journals target " + transaction.worldKey() + ".");
} }
} }
return List.copyOf(transactions); return List.copyOf(transactions);
} }
/**
* Whether this journal entry targets the current level root and logical world name. A
* mismatch means the operator changed level-name or world container after the replacement
* was staged; such entries are skipped at bootstrap instead of aborting the whole boot.
*/
public static boolean appliesTo(Transaction transaction, Path currentLevelRoot) {
Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction");
ExactWorldSlotPathPolicy.Target target;
try {
target = ExactWorldSlotPathPolicy.resolve(currentLevelRoot, requiredTransaction.worldKey());
} catch (RuntimeException failure) {
return false;
}
if (!target.levelRoot().equals(requiredTransaction.levelRoot())) {
return false;
}
String expectedWorldName;
try {
expectedWorldName = logicalWorldName(target.levelRoot(), requiredTransaction.worldKey());
} catch (IllegalArgumentException failure) {
return false;
}
return expectedWorldName.equals(requiredTransaction.worldName());
}
public static void write(Path dataDirectory, Transaction transaction) throws IOException { public static void write(Path dataDirectory, Transaction transaction) throws IOException {
Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction"); Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction");
Path directory = Objects.requireNonNull(directory(dataDirectory, true)); Path directory = Objects.requireNonNull(directory(dataDirectory, true));
@@ -145,7 +174,7 @@ public final class WorldReplacementJournal {
throw new IllegalArgumentException("World key is not an exact replaceable world slot: " + requiredWorldKey); throw new IllegalArgumentException("World key is not an exact replaceable world slot: " + requiredWorldKey);
} }
private static Transaction read(Path file, Path currentLevelRoot) throws IOException { private static Transaction read(Path file) throws IOException {
Properties properties = new Properties(); Properties properties = new Properties();
try (InputStream input = Files.newInputStream(file)) { try (InputStream input = Files.newInputStream(file)) {
properties.load(input); properties.load(input);
@@ -196,7 +225,6 @@ public final class WorldReplacementJournal {
originalTargetPresent, originalTargetPresent,
phase phase
); );
resolveTarget(transaction, currentLevelRoot);
return transaction; return transaction;
} }
@@ -342,25 +370,11 @@ public final class WorldReplacementJournal {
} }
private static void forceDirectoryRequired(Path directory) throws IOException { private static void forceDirectoryRequired(Path directory) throws IOException {
if (File.separatorChar == '\\') { DirectoryDurability.forceDirectoryRequired(directory);
return;
}
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
} catch (UnsupportedOperationException failure) {
throw new IOException("Directory durability sync is unavailable for " + directory + ".", failure);
}
} }
private static void forceDirectoryAfterCommit(Path directory) { private static void forceDirectoryAfterCommit(Path directory) {
try { DirectoryDurability.forceDirectoryAfterCommit(directory, "A world-replacement journal change");
forceDirectoryRequired(directory);
} catch (IOException failure) {
IrisLogging.reportError(
"A world-replacement journal change completed, but its parent directory could not be durability-synced.",
failure
);
}
} }
public record Transaction( public record Transaction(
@@ -507,6 +507,14 @@ public final class BukkitCommandMessagesExtended {
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks",
C.RED + "Pregen radius must be greater than zero blocks." C.RED + "Pregen radius must be greater than zero blocks."
); );
public static final TextKey COMMAND_STUDIO_REGIONS_RADIUS_OUT_OF_RANGE = TextKey.of(
"iris.bukkit.commandstudio.regions_radius_out_of_range",
C.RED + "Radius must be between 1 and 2048 chunks."
);
public static final TextKey COMMAND_STUDIO_REGIONS_SCAN_FAILED = TextKey.of(
"iris.bukkit.commandstudio.regions_scan_failed",
C.RED + "Region scan failed: {value}"
);
public static final TextKey COMMAND_PREGEN_STRICT_SERIAL_PREGENERATION_REQUIRES_PAPER_PAPER_COMPATIBLE_SERVER = TextKey.of( public static final TextKey COMMAND_PREGEN_STRICT_SERIAL_PREGENERATION_REQUIRES_PAPER_PAPER_COMPATIBLE_SERVER = TextKey.of(
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server",
C.RED + "Strict serial pregeneration requires Paper or a Paper-compatible server." C.RED + "Strict serial pregeneration requires Paper or a Paper-compatible server."
@@ -965,6 +973,8 @@ public final class BukkitCommandMessagesExtended {
COMMAND_OBJECT_NO_AREA_SELECTED_4, COMMAND_OBJECT_NO_AREA_SELECTED_4,
COMMAND_OBJECT_AUTO_SELECT_COMPLETE_2, COMMAND_OBJECT_AUTO_SELECT_COMPLETE_2,
COMMAND_PREGEN_PREGEN_RADIUS_MUST_BE_GREATER_THAN_ZERO_BLOCKS, COMMAND_PREGEN_PREGEN_RADIUS_MUST_BE_GREATER_THAN_ZERO_BLOCKS,
COMMAND_STUDIO_REGIONS_RADIUS_OUT_OF_RANGE,
COMMAND_STUDIO_REGIONS_SCAN_FAILED,
COMMAND_PREGEN_STRICT_SERIAL_PREGENERATION_REQUIRES_PAPER_PAPER_COMPATIBLE_SERVER, COMMAND_PREGEN_STRICT_SERIAL_PREGENERATION_REQUIRES_PAPER_PAPER_COMPATIBLE_SERVER,
COMMAND_PREGEN_ENGINE_ACCESS_THIS_WORLD_IS_NULL, COMMAND_PREGEN_ENGINE_ACCESS_THIS_WORLD_IS_NULL,
COMMAND_PREGEN_PLEASE_MAKE_SURE_WORLD_IS_LOADED_ENGINE_IS_INITIALIZED_GENERATE, COMMAND_PREGEN_PLEASE_MAKE_SURE_WORLD_IS_LOADED_ENGINE_IS_INITIALIZED_GENERATE,
@@ -23,6 +23,7 @@ import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineLifecycleTasks;
import art.arcane.iris.engine.framework.TreeBlockMaterial; import art.arcane.iris.engine.framework.TreeBlockMaterial;
import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.IObjectPlacer;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
@@ -270,13 +271,17 @@ public class TreeSVC implements IrisService {
IrisServices.get(ExternalDataSVC.class).processUpdate(engine, block, data.getCustom()); IrisServices.get(ExternalDataSVC.class).processUpdate(engine, block, data.getCustom());
} else block.setBlockData(d, false); } else block.setBlockData(d, false);
int mantleY = block.getY() - event.getWorld().getMinHeight(); int mantleY = block.getY() - event.getWorld().getMinHeight();
engine.getMantle().getMantle().set(block.getX(), mantleY, block.getZ(), treeMarker); // Lease-gated: the grow task can land after the engine started closing, and
engine.getMantle().getMantle().set( // an unleased mantle write would race the region flush.
block.getX(), EngineLifecycleTasks.run(engine, "tree_grow_mantle", () -> {
mantleY, engine.getMantle().getMantle().set(block.getX(), mantleY, block.getZ(), treeMarker);
block.getZ(), engine.getMantle().getMantle().set(
TreeBlockMaterial.of(block.getBlockData().getAsString()) block.getX(),
); mantleY,
block.getZ(),
TreeBlockMaterial.of(block.getBlockData().getAsString())
);
});
} }
} }
}; };
@@ -1,6 +1,7 @@
package art.arcane.iris.core.splash; package art.arcane.iris.core.splash;
import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.pack.PackDirectoryResolver;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.google.gson.JsonParseException; import com.google.gson.JsonParseException;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
@@ -51,12 +52,13 @@ public final class IrisSplashPackScanner {
try (FileReader reader = new FileReader(dimensionFile)) { try (FileReader reader = new FileReader(dimensionFile)) {
JsonObject json = JsonParser.parseReader(reader).getAsJsonObject(); JsonObject json = JsonParser.parseReader(reader).getAsJsonObject();
if (!json.has("version")) { JsonElement version = json.get("version");
if (version == null || !version.isJsonPrimitive()) {
return null; return null;
} }
return new SplashPackMetadata(dimName, json.get("version").getAsString()); return new SplashPackMetadata(dimName, version.getAsString());
} catch (IOException | JsonParseException | IllegalStateException error) { } catch (IOException | JsonParseException | IllegalStateException | UnsupportedOperationException error) {
report(reporter, "Failed to read splash metadata for dimension pack \"" + dimName + "\".", error); report(reporter, "Failed to read splash metadata for dimension pack \"" + dimName + "\".", error);
return null; return null;
} }
@@ -198,6 +198,9 @@ public class IrisConverter {
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_CONVERTER_SOME_SCHEMATICS_FAILED_CONVERT_CHECK_CONSOLE_DETAILS)); sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_CONVERTER_SOME_SCHEMATICS_FAILED_CONVERT_CHECK_CONSOLE_DETAILS));
} }
}); });
// Single-use pool: retire the worker once the queued conversion finishes instead of
// leaking a non-daemon thread per invocation.
executorService.shutdown();
} }
private static int resolveVersion(CompoundTag compound) throws Exception { private static int resolveVersion(CompoundTag compound) throws Exception {
@@ -39,6 +39,7 @@ import art.arcane.iris.core.pregenerator.methods.HybridPregenMethod;
import art.arcane.iris.core.service.GlobalCacheSVC; import art.arcane.iris.core.service.GlobalCacheSVC;
import art.arcane.iris.core.service.StudioSVC; import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineLifecycleTasks;
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;
@@ -643,7 +644,10 @@ public class IrisToolbelt {
if (e == null) { if (e == null) {
return null; return null;
} }
return e.getEngine().getMantle().getMantle().get(x, y - world.getMinHeight(), z, of); // Lease-gated so the engine shutdown drain covers these public accessors; a mantle
// mid-close refuses the lease instead of racing the region flush.
return EngineLifecycleTasks.call(e.getEngine(), "api_mantle_get",
() -> e.getEngine().getMantle().getMantle().get(x, y - world.getMinHeight(), z, of), null);
} }
public static <T> void deleteMantleData(World world, int x, int y, int z, Class<T> of) { public static <T> void deleteMantleData(World world, int x, int y, int z, Class<T> of) {
@@ -651,7 +655,8 @@ public class IrisToolbelt {
if (e == null) { if (e == null) {
return; return;
} }
e.getEngine().getMantle().getMantle().remove(x, y - world.getMinHeight(), z, of); EngineLifecycleTasks.run(e.getEngine(), "api_mantle_delete",
() -> e.getEngine().getMantle().getMantle().remove(x, y - world.getMinHeight(), z, of));
} }
public static <T> void setMantleData(World world, int x, int y, int z, T data) { public static <T> void setMantleData(World world, int x, int y, int z, T data) {
@@ -659,7 +664,8 @@ public class IrisToolbelt {
if (e == null || data == null) { if (e == null || data == null) {
return; return;
} }
e.getEngine().getMantle().getMantle().set(x, y - world.getMinHeight(), z, data); EngineLifecycleTasks.run(e.getEngine(), "api_mantle_set",
() -> e.getEngine().getMantle().getMantle().set(x, y - world.getMinHeight(), z, data));
} }
public static boolean removeWorld(World world) throws IOException { public static boolean removeWorld(World world) throws IOException {
@@ -24,9 +24,9 @@ public final class WorldMaintenance {
return; return;
} }
int depth = worldMaintenanceDepth.computeIfAbsent(worldName, k -> new AtomicInteger()).incrementAndGet(); int depth = incrementDepth(worldMaintenanceDepth, worldName);
if (bypassMantleStages) { if (bypassMantleStages) {
worldMaintenanceMantleBypassDepth.computeIfAbsent(worldName, k -> new AtomicInteger()).incrementAndGet(); incrementDepth(worldMaintenanceMantleBypassDepth, worldName);
} }
if (IrisSettings.get().getGeneral().isDebug()) { if (IrisSettings.get().getGeneral().isDebug()) {
IrisLogging.info("World maintenance enter: " + worldName + " reason=" + reason + " depth=" + depth + " bypassMantle=" + bypassMantleStages); IrisLogging.info("World maintenance enter: " + worldName + " reason=" + reason + " depth=" + depth + " bypassMantle=" + bypassMantleStages);
@@ -44,29 +44,17 @@ public final class WorldMaintenance {
return; return;
} }
AtomicInteger depthCounter = worldMaintenanceDepth.get(worldName); if (!worldMaintenanceDepth.containsKey(worldName)) {
if (depthCounter == null) {
return; return;
} }
int depth = depthCounter.decrementAndGet(); int depth = decrementDepth(worldMaintenanceDepth, worldName);
if (depth <= 0) {
worldMaintenanceDepth.remove(worldName, depthCounter);
depth = 0;
}
// Only a bypass-begin's paired end releases bypass credit: a plain end overlapping a // Only a bypass-begin's paired end releases bypass credit: a plain end overlapping a
// bypassing operation stole its credit and re-enabled mantle stages under it. // bypassing operation stole its credit and re-enabled mantle stages under it.
int bypassDepth = 0; int bypassDepth = 0;
if (bypassMantleStages) { if (bypassMantleStages) {
AtomicInteger bypassCounter = worldMaintenanceMantleBypassDepth.get(worldName); bypassDepth = decrementDepth(worldMaintenanceMantleBypassDepth, worldName);
if (bypassCounter != null) {
bypassDepth = bypassCounter.decrementAndGet();
if (bypassDepth <= 0) {
worldMaintenanceMantleBypassDepth.remove(worldName, bypassCounter);
bypassDepth = 0;
}
}
} }
if (IrisSettings.get().getGeneral().isDebug()) { if (IrisSettings.get().getGeneral().isDebug()) {
@@ -76,6 +64,22 @@ public final class WorldMaintenance {
} }
} }
// Depth mutations run inside compute() so an identity-based remove can never clear a
// registration a concurrent begin just re-incremented.
private static int incrementDepth(Map<String, AtomicInteger> depths, String worldName) {
return depths.compute(worldName, (key, current) -> {
AtomicInteger counter = current == null ? new AtomicInteger() : current;
counter.incrementAndGet();
return counter;
}).get();
}
private static int decrementDepth(Map<String, AtomicInteger> depths, String worldName) {
AtomicInteger remaining = depths.computeIfPresent(
worldName, (key, current) -> current.decrementAndGet() <= 0 ? null : current);
return remaining == null ? 0 : Math.max(0, remaining.get());
}
public static boolean isWorldMaintenanceActive(String worldName) { public static boolean isWorldMaintenanceActive(String worldName) {
if (worldName == null) { if (worldName == null) {
return false; return false;
@@ -34,7 +34,6 @@ import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.protocol.IrisMessage; import art.arcane.iris.spi.protocol.IrisMessage;
import art.arcane.iris.util.project.context.IrisContext; import art.arcane.iris.util.project.context.IrisContext;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.math.RNG;
import static art.arcane.iris.engine.EngineShutdownSequence.runCleanup; import static art.arcane.iris.engine.EngineShutdownSequence.runCleanup;
@@ -64,7 +63,7 @@ final class EngineHotloader {
IrisComplex nextComplex = null; IrisComplex nextComplex = null;
try { try {
engine.sealForTransition("complex hotload", false); engine.sealForTransition("complex hotload", false);
RuntimeAssembly assembly = new RuntimeAssembly(RNG.r.nextInt(), previous.target()); RuntimeAssembly assembly = new RuntimeAssembly(RuntimeAssembly.nextRuntimeId(), previous.target());
engine.runtimeAssembly.set(assembly); engine.runtimeAssembly.set(assembly);
EngineRuntime next; EngineRuntime next;
try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) {
@@ -40,10 +40,10 @@ import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.util.project.context.IrisContext; import art.arcane.iris.util.project.context.IrisContext;
import art.arcane.volmlib.util.io.IO; import art.arcane.volmlib.util.io.IO;
import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.math.M;
import art.arcane.volmlib.util.math.RNG;
import java.io.File; import java.io.File;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import static art.arcane.iris.engine.EngineShutdownSequence.propagate; import static art.arcane.iris.engine.EngineShutdownSequence.propagate;
@@ -65,7 +65,7 @@ final class EngineRuntimeBuilder {
} }
EngineRuntime buildRuntime(EngineTarget runtimeTarget) { EngineRuntime buildRuntime(EngineTarget runtimeTarget) {
RuntimeAssembly assembly = new RuntimeAssembly(RNG.r.nextInt(), runtimeTarget); RuntimeAssembly assembly = new RuntimeAssembly(RuntimeAssembly.nextRuntimeId(), runtimeTarget);
engine.runtimeAssembly.set(assembly); engine.runtimeAssembly.set(assembly);
try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) {
IrisLogging.debug("Setup Engine " + assembly.cacheId); IrisLogging.debug("Setup Engine " + assembly.cacheId);
@@ -279,8 +279,14 @@ final class EngineRuntimeBuilder {
} }
static final class RuntimeAssembly { static final class RuntimeAssembly {
private static final AtomicInteger RUNTIME_IDS = new AtomicInteger();
final int cacheId; final int cacheId;
final EngineTarget target; final EngineTarget target;
static int nextRuntimeId() {
return RUNTIME_IDS.incrementAndGet();
}
IrisComplex complex; IrisComplex complex;
UpperDimensionContext upperContext; UpperDimensionContext upperContext;
EngineEffects effects; EngineEffects effects;
@@ -97,7 +97,11 @@ public class LinkedTerrainChunk implements TerrainChunk {
@Override @Override
public synchronized void setRegion(int xMin, int yMin, int zMin, int xMax, int yMax, int zMax, PlatformBlockState state) { public synchronized void setRegion(int xMin, int yMin, int zMin, int xMax, int yMax, int zMax, PlatformBlockState state) {
rawChunkData.setRegion(xMin, yMin, zMin, xMax, yMax, zMax, (BlockData) state.nativeHandle()); BlockData blockData = (BlockData) state.nativeHandle();
if (blockData instanceof IrisCustomData data) {
blockData = data.getBase();
}
rawChunkData.setRegion(xMin, yMin, zMin, xMax, yMax, zMax, blockData);
} }
@Override @Override
@@ -33,7 +33,44 @@ public record NativeStructureVolume(
int maxY, int maxY,
int maxZ int maxZ
) { ) {
public static final KList<NativeStructureVolume> NONE = new KList<>(); // Shared empty sentinel handed to every engine and memoized by the volume caches; a silent
// add()/clear() here would corrupt them all globally, so mutation fails loudly instead.
public static final KList<NativeStructureVolume> NONE = new KList<>() {
@Override
public boolean add(NativeStructureVolume volume) {
throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable");
}
@Override
public void add(int index, NativeStructureVolume volume) {
throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable");
}
@Override
public boolean addAll(java.util.Collection<? extends NativeStructureVolume> volumes) {
throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable");
}
@Override
public boolean addAll(int index, java.util.Collection<? extends NativeStructureVolume> volumes) {
throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable");
}
@Override
public void clear() {
throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable");
}
@Override
public NativeStructureVolume remove(int index) {
throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable");
}
@Override
public boolean remove(Object volume) {
throw new UnsupportedOperationException("NativeStructureVolume.NONE is immutable");
}
};
public static NativeStructureVolume of(String structure, int aX, int aY, int aZ, int bX, int bY, int bZ) { public static NativeStructureVolume of(String structure, int aX, int aY, int aZ, int bX, int bY, int bZ) {
return new NativeStructureVolume( return new NativeStructureVolume(
@@ -231,6 +231,10 @@ public final class IrisObjectIO {
static void write(IrisObject self, OutputStream o) throws IOException { static void write(IrisObject self, OutputStream o) throws IOException {
validateWritable(self); validateWritable(self);
writeValidated(self, o);
}
private static void writeValidated(IrisObject self, OutputStream o) throws IOException {
DataOutputStream dos = new DataOutputStream(o); DataOutputStream dos = new DataOutputStream(o);
dos.writeInt(self.w); dos.writeInt(self.w);
dos.writeInt(self.h); dos.writeInt(self.h);
@@ -270,6 +274,10 @@ public final class IrisObjectIO {
static void write(IrisObject self, OutputStream o, VolmitSender sender) throws IOException { static void write(IrisObject self, OutputStream o, VolmitSender sender) throws IOException {
validateWritable(self); validateWritable(self);
writeValidated(self, o, sender);
}
private static void writeValidated(IrisObject self, OutputStream o, VolmitSender sender) throws IOException {
AtomicReference<IOException> ref = new AtomicReference<>(); AtomicReference<IOException> ref = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1); CountDownLatch latch = new CountDownLatch(1);
new Job() { new Job() {
@@ -365,7 +373,7 @@ public final class IrisObjectIO {
// object must leave the existing .iob untouched. // object must leave the existing .iob untouched.
validateWritable(self); validateWritable(self);
try (FileOutputStream out = new FileOutputStream(file)) { try (FileOutputStream out = new FileOutputStream(file)) {
write(self, out); writeValidated(self, out);
} }
} }
@@ -376,7 +384,7 @@ public final class IrisObjectIO {
validateWritable(self); validateWritable(self);
try (FileOutputStream out = new FileOutputStream(file)) { try (FileOutputStream out = new FileOutputStream(file)) {
write(self, out, sender); writeValidated(self, out, sender);
} }
} }
@@ -482,9 +482,10 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
if (throwable == null) { if (throwable == null) {
future.complete(null); future.complete(null);
} else { } else {
// The close body already ran and tore the engine down; unlike the pre-dispatch
// failure above, resetting the gate here would advertise a healthy generator
// over a CLOSED or FAILED engine. Stay latched and surface the failure.
future.completeExceptionally(throwable); future.completeExceptionally(throwable);
closeFuture.compareAndSet(future, null);
closing = false;
} }
}); });
return future; return future;
@@ -75,19 +75,4 @@ public class VectorMath extends art.arcane.volmlib.util.math.VectorMath {
return v; return v;
} }
public static Vector getAxis(Direction current, Direction to) {
if (current.equals(Direction.U) || current.equals(Direction.D)) {
if (to.equals(Direction.U) || to.equals(Direction.D)) {
return new Vector(1, 0, 0);
} else {
if (current.equals(Direction.N) || current.equals(Direction.S)) {
return Direction.E.toVector();
} else {
return Direction.S.toVector();
}
}
}
return new Vector(0, 1, 0);
}
} }
@@ -3,9 +3,14 @@ package art.arcane.iris.util.common.parallel;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.parallel.BurstExecutorSupport; import art.arcane.volmlib.util.parallel.BurstExecutorSupport;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.function.Supplier;
public class BurstExecutor extends BurstExecutorSupport { public class BurstExecutor extends BurstExecutorSupport {
public BurstExecutor(ExecutorService executor, int burstSizeEstimate) { public BurstExecutor(ExecutorService executor, int burstSizeEstimate) {
super(executor, burstSizeEstimate, IrisLogging::reportError); super(executor, burstSizeEstimate, IrisLogging::reportError);
} }
public BurstExecutor(Supplier<ExecutorService> executorSource, int burstSizeEstimate) {
super(executorSource, burstSizeEstimate, IrisLogging::reportError);
}
} }
@@ -72,7 +72,7 @@ public class MultiBurst extends MultiBurstSupport {
@Override @Override
public BurstExecutor burst(int estimate) { public BurstExecutor burst(int estimate) {
return new BurstExecutor(service(), estimate); return new BurstExecutor(this::service, estimate);
} }
@Override @Override
@@ -21,6 +21,7 @@ package art.arcane.iris.util.project.hunk.view;
import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.data.B; import art.arcane.iris.util.common.data.B;
import art.arcane.iris.util.common.data.IrisCustomData;
import art.arcane.iris.util.project.hunk.storage.AtomicHunk; import art.arcane.iris.util.project.hunk.storage.AtomicHunk;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
import org.bukkit.generator.ChunkGenerator.ChunkData; import org.bukkit.generator.ChunkGenerator.ChunkData;
@@ -88,6 +89,11 @@ public class ChunkDataHunkHolder extends AtomicHunk<PlatformBlockState> {
for (int y = 0; y < height; y++) { for (int y = 0; y < height; y++) {
PlatformBlockState state = super.getRaw(x, y, z); PlatformBlockState state = super.getRaw(x, y, z);
BlockData block = state == null ? null : (BlockData) state.nativeHandle(); BlockData block = state == null ? null : (BlockData) state.nativeHandle();
// Custom wrappers are not real Bukkit data; write the vanilla base like the
// NMS fast path (NMSBinding.applyChunkDataBlocks) does.
if (block instanceof IrisCustomData custom) {
block = custom.getBase();
}
if (block == null) { if (block == null) {
flushRun(x, z, runStart, y, activeBlock); flushRun(x, z, runStart, y, activeBlock);
activeBlock = null; activeBlock = null;
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "Kein Bereich ausgewählt.", "iris.bukkit.commandobject.no_area_selected_4": "Kein Bereich ausgewählt.",
"iris.bukkit.commandobject.auto_select_complete_2": "§aAutomatische Auswahl abgeschlossen!", "iris.bukkit.commandobject.auto_select_complete_2": "§aAutomatische Auswahl abgeschlossen!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cDer Vorgenerierungsradius muss größer als null Blöcke sein.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cDer Vorgenerierungsradius muss größer als null Blöcke sein.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§cDer Radius muss zwischen 1 und 2048 Chunks liegen.",
"iris.bukkit.commandstudio.regions_scan_failed": "§cRegion-Scan fehlgeschlagen: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cDie strikt serielle Vorgenerierung erfordert Paper oder einen Paper-kompatiblen Server.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cDie strikt serielle Vorgenerierung erfordert Paper oder einen Paper-kompatiblen Server.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cDer Engine-Zugriff für diese Welt ist null!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cDer Engine-Zugriff für diese Welt ist null!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cStelle sicher, dass die Welt geladen und die Engine initialisiert ist. Generiere zum Beispiel einen neuen Chunk.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cStelle sicher, dass die Welt geladen und die Engine initialisiert ist. Generiere zum Beispiel einen neuen Chunk.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "No hay ningún área seleccionada.", "iris.bukkit.commandobject.no_area_selected_4": "No hay ningún área seleccionada.",
"iris.bukkit.commandobject.auto_select_complete_2": "§a¡Selección automática completada!", "iris.bukkit.commandobject.auto_select_complete_2": "§a¡Selección automática completada!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cEl radio de pregen debe ser mayor que cero bloques.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cEl radio de pregen debe ser mayor que cero bloques.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§cEl radio debe estar entre 1 y 2048 chunks.",
"iris.bukkit.commandstudio.regions_scan_failed": "§cError al escanear regiones: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cLa pregeneración estrictamente secuencial requiere Paper o un servidor compatible con Paper.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cLa pregeneración estrictamente secuencial requiere Paper o un servidor compatible con Paper.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§c¡El acceso al motor de este mundo es nulo!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§c¡El acceso al motor de este mundo es nulo!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cAsegúrate de que el mundo esté cargado y el motor inicializado. Por ejemplo, genera un chunk nuevo.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cAsegúrate de que el mundo esté cargado y el motor inicializado. Por ejemplo, genera un chunk nuevo.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "Aluetta ei ole valittu.", "iris.bukkit.commandobject.no_area_selected_4": "Aluetta ei ole valittu.",
"iris.bukkit.commandobject.auto_select_complete_2": "§aAutomaattivalinta valmis!", "iris.bukkit.commandobject.auto_select_complete_2": "§aAutomaattivalinta valmis!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen-säteen on oltava suurempi kuin nolla lohkoa.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen-säteen on oltava suurempi kuin nolla lohkoa.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§cSäteen on oltava 1 ja 2048 chunkin välillä.",
"iris.bukkit.commandstudio.regions_scan_failed": "§cAlueskannaus epäonnistui: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cTiukasti sarjallinen esigenerointi vaatii Paperin tai Paper-yhteensopivan palvelimen.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cTiukasti sarjallinen esigenerointi vaatii Paperin tai Paper-yhteensopivan palvelimen.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cTämän maailman moottorit eivät toimi!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cTämän maailman moottorit eivät toimi!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cVarmista, että maailma on ladattu. & Moottori on alustettu. Luo esimerkiksi uusi pala.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cVarmista, että maailma on ladattu. & Moottori on alustettu. Luo esimerkiksi uusi pala.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "Aucune zone sélectionnée.", "iris.bukkit.commandobject.no_area_selected_4": "Aucune zone sélectionnée.",
"iris.bukkit.commandobject.auto_select_complete_2": "§aSélection automatique terminée !", "iris.bukkit.commandobject.auto_select_complete_2": "§aSélection automatique terminée !",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cLe rayon de pregen doit être supérieur à zéro bloc.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cLe rayon de pregen doit être supérieur à zéro bloc.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§cLe rayon doit être compris entre 1 et 2048 chunks.",
"iris.bukkit.commandstudio.regions_scan_failed": "§cÉchec de l'analyse des régions : {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cLa prégénération strictement séquentielle nécessite Paper ou un serveur compatible avec Paper.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cLa prégénération strictement séquentielle nécessite Paper ou un serveur compatible avec Paper.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cL'accès au moteur de ce monde est nul !", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cL'accès au moteur de ce monde est nul !",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cVérifiez que le monde est chargé et que le moteur est initialisé. Générez par exemple un nouveau chunk.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cVérifiez que le monde est chargé et que le moteur est initialisé. Générez par exemple un nouveau chunk.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "שום אזור לא נבחר.", "iris.bukkit.commandobject.no_area_selected_4": "שום אזור לא נבחר.",
"iris.bukkit.commandobject.auto_select_complete_2": "§aבחירה אוטומטית הושלם!", "iris.bukkit.commandobject.auto_select_complete_2": "§aבחירה אוטומטית הושלם!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cרדיוס Pregen חייב להיות גדול יותר מאשר אפס בלוקים.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cרדיוס Pregen חייב להיות גדול יותר מאשר אפס בלוקים.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§cהרדיוס חייב להיות בין 1 ל-2048 צ'אנקים.",
"iris.bukkit.commandstudio.regions_scan_failed": "§cסריקת האזורים נכשלה: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cקדם-יצירה טורית קפדנית דורשת Paper או שרת תואם Paper.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cקדם-יצירה טורית קפדנית דורשת Paper או שרת תואם Paper.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cהגישה של המנוע לעולם הזה היא אפס!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cהגישה של המנוע לעולם הזה היא אפס!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cודא שהעולם טעון & המנוע הוא ראשוני. ליצור נתח חדש, למשל.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cודא שהעולם טעון & המנוע הוא ראשוני. ליצור נתח חדש, למשל.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "Nessuna area selezionata.", "iris.bukkit.commandobject.no_area_selected_4": "Nessuna area selezionata.",
"iris.bukkit.commandobject.auto_select_complete_2": "§aAuto-selezione completa!", "iris.bukkit.commandobject.auto_select_complete_2": "§aAuto-selezione completa!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cIl raggio di pregenerazione deve essere maggiore di zero blocchi.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cIl raggio di pregenerazione deve essere maggiore di zero blocchi.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§cIl raggio deve essere compreso tra 1 e 2048 chunk.",
"iris.bukkit.commandstudio.regions_scan_failed": "§cScansione delle regioni non riuscita: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cLa pregenerazione strettamente seriale richiede Paper o un server compatibile con Paper.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cLa pregenerazione strettamente seriale richiede Paper o un server compatibile con Paper.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cL'accesso all'engine per questo mondo è nullo!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cL'accesso all'engine per questo mondo è nullo!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cAssicurati che il mondo sia caricato e che l'engine sia inizializzato. Ad esempio, genera un nuovo chunk.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cAssicurati che il mondo sia caricato e che l'engine sia inizializzato. Ad esempio, genera un nuovo chunk.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "範囲が選択されていません。", "iris.bukkit.commandobject.no_area_selected_4": "範囲が選択されていません。",
"iris.bukkit.commandobject.auto_select_complete_2": "§a自動選択完了!", "iris.bukkit.commandobject.auto_select_complete_2": "§a自動選択完了!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§c事前生成の半径は 0 ブロックより大きくなければなりません。", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§c事前生成の半径は 0 ブロックより大きくなければなりません。",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§c半径は1から2048チャンクの範囲で指定してください。",
"iris.bukkit.commandstudio.regions_scan_failed": "§cリージョンスキャンに失敗しました: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§c厳密な直列事前生成には Paper または Paper 互換サーバーが必要です。", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§c厳密な直列事前生成には Paper または Paper 互換サーバーが必要です。",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cこのワールドのエンジンにアクセスできません!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cこのワールドのエンジンにアクセスできません!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cワールドが読み込まれ、エンジンが初期化されていることを確認してください。たとえば、新しいチャンクを生成してください。", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cワールドが読み込まれ、エンジンが初期化されていることを確認してください。たとえば、新しいチャンクを生成してください。",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "선택 없음.", "iris.bukkit.commandobject.no_area_selected_4": "선택 없음.",
"iris.bukkit.commandobject.auto_select_complete_2": "§a자동 선택 완료!", "iris.bukkit.commandobject.auto_select_complete_2": "§a자동 선택 완료!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen 반경은 0 개 이상의 블록이 있어야합니다.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen 반경은 0 개 이상의 블록이 있어야합니다.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§c반경은 1~2048 청크 사이여야 합니다.",
"iris.bukkit.commandstudio.regions_scan_failed": "§c지역 스캔에 실패했습니다: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§c엄격한 순차 사전 생성에는 Paper 또는 Paper 호환 서버가 필요합니다.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§c엄격한 순차 사전 생성에는 Paper 또는 Paper 호환 서버가 필요합니다.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§c이 세상의 엔진 접근은 null입니다!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§c이 세상의 엔진 접근은 null입니다!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§c세상이 로드되었는지 확인하십시오. & 엔진은 초기화됩니다. 새로운 청크 생성, 예를 들어.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§c세상이 로드되었는지 확인하십시오. & 엔진은 초기화됩니다. 새로운 청크 생성, 예를 들어.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "Nepasirinkta sritis.", "iris.bukkit.commandobject.no_area_selected_4": "Nepasirinkta sritis.",
"iris.bukkit.commandobject.auto_select_complete_2": "§aAuto-pasirinkite baigtas!", "iris.bukkit.commandobject.auto_select_complete_2": "§aAuto-pasirinkite baigtas!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen spindulys turi būti didesnis nei nulis blokų.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen spindulys turi būti didesnis nei nulis blokų.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§cSpindulys turi būti nuo 1 iki 2048 gabalų.",
"iris.bukkit.commandstudio.regions_scan_failed": "§cRegionų nuskaitymas nepavyko: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cGriežtai nuosekliam išankstiniam generavimui reikia Paper arba su Paper suderinamo serverio.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cGriežtai nuosekliam išankstiniam generavimui reikia Paper arba su Paper suderinamo serverio.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cVariklio pasiekiamumas šiam pasauliui nulinis!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cVariklio pasiekiamumas šiam pasauliui nulinis!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cĮsitikinkite, kad pasaulis įkeltas & variklis įjungiamas. Pavyzdžiui, generuoti naują gabalą.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cĮsitikinkite, kad pasaulis įkeltas & variklis įjungiamas. Pavyzdžiui, generuoti naują gabalą.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "Geen gebied geselecteerd.", "iris.bukkit.commandobject.no_area_selected_4": "Geen gebied geselecteerd.",
"iris.bukkit.commandobject.auto_select_complete_2": "§aAutomatisch selecteren voltooid!", "iris.bukkit.commandobject.auto_select_complete_2": "§aAutomatisch selecteren voltooid!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen radius moet groter zijn dan nul blokken.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen radius moet groter zijn dan nul blokken.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§cDe radius moet tussen 1 en 2048 chunks liggen.",
"iris.bukkit.commandstudio.regions_scan_failed": "§cRegioscan mislukt: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cStrikt seriële pregeneratie vereist Paper of een Paper-compatibele server.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cStrikt seriële pregeneratie vereist Paper of een Paper-compatibele server.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cDe toegang tot de motor voor deze wereld is nul!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cDe toegang tot de motor voor deze wereld is nul!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cZorg ervoor dat de wereld geladen is & de motor is geïnitialiseerd. Genereer bijvoorbeeld een nieuwe chunk.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cZorg ervoor dat de wereld geladen is & de motor is geïnitialiseerd. Genereer bijvoorbeeld een nieuwe chunk.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "Nie wybrano obszaru.", "iris.bukkit.commandobject.no_area_selected_4": "Nie wybrano obszaru.",
"iris.bukkit.commandobject.auto_select_complete_2": "§aAuto- select zakończone!", "iris.bukkit.commandobject.auto_select_complete_2": "§aAuto- select zakończone!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPromień pregen musi być większy niż zero bloków.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPromień pregen musi być większy niż zero bloków.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§cPromień musi mieścić się w zakresie od 1 do 2048 chunków.",
"iris.bukkit.commandstudio.regions_scan_failed": "§cSkanowanie regionów nie powiodło się: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cŚciśle szeregowa pregeneracja wymaga Paper lub serwera zgodnego z Paper.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cŚciśle szeregowa pregeneracja wymaga Paper lub serwera zgodnego z Paper.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cSilnik dostępu dla tego świata jest zerowy!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cSilnik dostępu dla tego świata jest zerowy!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cUpewnij się, że świat jest załadowany. & silnik jest inicjalizowany. Na przykład wygenerować nowy kawałek.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cUpewnij się, że świat jest załadowany. & silnik jest inicjalizowany. Na przykład wygenerować nowy kawałek.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "Nenhuma área selecionada.", "iris.bukkit.commandobject.no_area_selected_4": "Nenhuma área selecionada.",
"iris.bukkit.commandobject.auto_select_complete_2": "§aSelecção automática completa!", "iris.bukkit.commandobject.auto_select_complete_2": "§aSelecção automática completa!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cO raio de Pregen deve ser superior a zero blocos.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cO raio de Pregen deve ser superior a zero blocos.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§cO raio deve estar entre 1 e 2048 chunks.",
"iris.bukkit.commandstudio.regions_scan_failed": "§cFalha ao analisar regiões: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cA pré-geração estritamente sequencial requer Paper ou um servidor compatível com Paper.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cA pré-geração estritamente sequencial requer Paper ou um servidor compatível com Paper.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cO acesso do motor para este mundo é nulo!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cO acesso do motor para este mundo é nulo!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cPor favor, certifique-se que o mundo está carregado & O motor está inicializado. Gerar um novo chunk, por exemplo.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cPor favor, certifique-se que o mundo está carregado & O motor está inicializado. Gerar um novo chunk, por exemplo.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "Никакой области не выбрано.", "iris.bukkit.commandobject.no_area_selected_4": "Никакой области не выбрано.",
"iris.bukkit.commandobject.auto_select_complete_2": "§aАвтовыбор завершен!", "iris.bukkit.commandobject.auto_select_complete_2": "§aАвтовыбор завершен!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cРадиус прегена должен быть больше нуля блоков.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cРадиус прегена должен быть больше нуля блоков.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§cРадиус должен быть от 1 до 2048 чанков.",
"iris.bukkit.commandstudio.regions_scan_failed": "§cНе удалось просканировать регионы: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cСтрого последовательная прегенерация требует Paper или Paper-совместимый сервер.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cСтрого последовательная прегенерация требует Paper или Paper-совместимый сервер.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cДоступ к двигателю для этого мира недействителен!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cДоступ к двигателю для этого мира недействителен!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cУбедитесь, что мир загружен & Двигатель инициализируется. Например, создать новый чанк.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cУбедитесь, что мир загружен & Двигатель инициализируется. Например, создать новый чанк.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "Hiçbir alan seçilmiş değildir.", "iris.bukkit.commandobject.no_area_selected_4": "Hiçbir alan seçilmiş değildir.",
"iris.bukkit.commandobject.auto_select_complete_2": "§aotomatik seçim tamamen!", "iris.bukkit.commandobject.auto_select_complete_2": "§aotomatik seçim tamamen!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen radius sıfır bloklardan daha büyük olmalıdır.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen radius sıfır bloklardan daha büyük olmalıdır.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§cYarıçap 1 ile 2048 chunk arasında olmalıdır.",
"iris.bukkit.commandstudio.regions_scan_failed": "§cBölge taraması başarısız oldu: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cKesin sıralı ön oluşturma Paper veya Paper uyumlu bir sunucu gerektirir.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cKesin sıralı ön oluşturma Paper veya Paper uyumlu bir sunucu gerektirir.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cBu dünya için motor erişimi çıplak!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cBu dünya için motor erişimi çıplak!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cLütfen dünyanın yüklendiğinden emin olun & Motor başlangıçlı. Örneğin yeni bir chunk.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cLütfen dünyanın yüklendiğinden emin olun & Motor başlangıçlı. Örneğin yeni bir chunk.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "Chưa chọn diện tích.", "iris.bukkit.commandobject.no_area_selected_4": "Chưa chọn diện tích.",
"iris.bukkit.commandobject.auto_select_complete_2": "§aTự động chọn hoàn tất!", "iris.bukkit.commandobject.auto_select_complete_2": "§aTự động chọn hoàn tất!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cBán kính Pregen phải lớn hơn không khối.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cBán kính Pregen phải lớn hơn không khối.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§cBán kính phải nằm trong khoảng từ 1 đến 2048 chunk.",
"iris.bukkit.commandstudio.regions_scan_failed": "§cQuét khu vực thất bại: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cTiền tạo tuần tự nghiêm ngặt yêu cầu Paper hoặc máy chủ tương thích với Paper.", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§cTiền tạo tuần tự nghiêm ngặt yêu cầu Paper hoặc máy chủ tương thích với Paper.",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cĐộng cơ truy cập thế giới này là vô ích!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§cĐộng cơ truy cập thế giới này là vô ích!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cHãy chắc chắn rằng thế giới đã lên đạn. & Động cơ đã khởi động. Chẳng hạn như tạo ra một mảng mới.", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§cHãy chắc chắn rằng thế giới đã lên đạn. & Động cơ đã khởi động. Chẳng hạn như tạo ra một mảng mới.",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "未选择区域 。", "iris.bukkit.commandobject.no_area_selected_4": "未选择区域 。",
"iris.bukkit.commandobject.auto_select_complete_2": "§a自动选择完成!", "iris.bukkit.commandobject.auto_select_complete_2": "§a自动选择完成!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen半径必须大于零块.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen半径必须大于零块.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§c半径必须在 1 到 2048 区块之间。",
"iris.bukkit.commandstudio.regions_scan_failed": "§c区域扫描失败: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§c严格的序列预生成要求 Paper 或一个 Paper- 兼容服务器。", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§c严格的序列预生成要求 Paper 或一个 Paper- 兼容服务器。",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§c这个世界的引擎是无效的!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§c这个世界的引擎是无效的!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§c请确保世界充满 & 引擎已经初始化。 例如, 生成一个新的块 。", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§c请确保世界充满 & 引擎已经初始化。 例如, 生成一个新的块 。",
@@ -232,6 +232,8 @@
"iris.bukkit.commandobject.no_area_selected_4": "未選擇區域 。", "iris.bukkit.commandobject.no_area_selected_4": "未選擇區域 。",
"iris.bukkit.commandobject.auto_select_complete_2": "§a自動選擇完成!", "iris.bukkit.commandobject.auto_select_complete_2": "§a自動選擇完成!",
"iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen半徑必須大於零塊.", "iris.bukkit.commandpregen.pregen_radius_must_be_greater_than_zero_blocks": "§cPregen半徑必須大於零塊.",
"iris.bukkit.commandstudio.regions_radius_out_of_range": "§c半徑必須介於 1 到 2048 區塊之間。",
"iris.bukkit.commandstudio.regions_scan_failed": "§c區域掃描失敗: {value}",
"iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§c嚴格的序列預生成要求 Paper 或一個 Paper- 相容伺服器。", "iris.bukkit.commandpregen.strict_serial_pregeneration_requires_paper_paper_compatible_server": "§c嚴格的序列預生成要求 Paper 或一個 Paper- 相容伺服器。",
"iris.bukkit.commandpregen.engine_access_this_world_is_null": "§c這個世界的引擎是無效的!", "iris.bukkit.commandpregen.engine_access_this_world_is_null": "§c這個世界的引擎是無效的!",
"iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§c請確保世界充滿 & 引擎已經初始化。 例如, 生成一個新的塊 。", "iris.bukkit.commandpregen.please_make_sure_world_is_loaded_engine_is_initialized_generate": "§c請確保世界充滿 & 引擎已經初始化。 例如, 生成一個新的塊 。",
@@ -1,9 +1,13 @@
package art.arcane.iris.core.lifecycle; package art.arcane.iris.core.lifecycle;
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.event.world.WorldUnloadEvent;
import org.bukkit.plugin.PluginManager;
import org.junit.Test; import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@@ -16,10 +20,43 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; 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;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class WorldLifecycleUnloadAsyncTest { public class WorldLifecycleUnloadAsyncTest {
@Test
public void manualUnloadFallbackDispatchesWorldUnloadEvent() {
World world = mock(World.class);
PluginManager pluginManager = mock(PluginManager.class);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
assertTrue(WorldLifecycleSupport.announceManualWorldUnload(world));
}
verify(pluginManager).callEvent(any(WorldUnloadEvent.class));
}
@Test
public void manualUnloadFallbackHonorsCancelledWorldUnloadEvent() {
World world = mock(World.class);
PluginManager pluginManager = mock(PluginManager.class);
doAnswer(invocation -> {
WorldUnloadEvent event = invocation.getArgument(0);
event.setCancelled(true);
return null;
}).when(pluginManager).callEvent(any(WorldUnloadEvent.class));
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
assertFalse(WorldLifecycleSupport.announceManualWorldUnload(world));
}
}
@Test @Test
public void reflectedAsyncUnloadWaitsForTrueCallback() throws Exception { public void reflectedAsyncUnloadWaitsForTrueCallback() throws Exception {
CallbackServer server = new CallbackServer(); CallbackServer server = new CallbackServer();
@@ -192,22 +192,22 @@ public class WorldReplacementBootstrapTest {
} }
@Test @Test
public void rejectsChangedLevelRootBeforeTouchingStagedStorage() throws Exception { public void skipsChangedLevelRootWithoutTouchingStagedStorage() throws Exception {
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original"); Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
configureReplacement(transaction); configureReplacement(transaction);
Path otherLevelRoot = Files.createDirectories(serverRoot.resolve("renamed-world")); Path otherLevelRoot = Files.createDirectories(serverRoot.resolve("renamed-world"));
assertThrows( WorldReplacementBootstrap.ReconcileResult result = WorldReplacementBootstrap.reconcile(
IOException.class, dataDirectory,
() -> WorldReplacementBootstrap.reconcile( otherLevelRoot,
dataDirectory, bukkitConfiguration,
otherLevelRoot, ignored -> {
bukkitConfiguration, }
ignored -> {
}
)
); );
assertEquals(1, result.skipped());
assertEquals(0, result.published());
assertEquals(0, result.rolledBack());
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt"))); assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
assertTrue(Files.isDirectory(paths(transaction).stage())); assertTrue(Files.isDirectory(paths(transaction).stage()));
assertFalse(Files.exists(paths(transaction).backup())); assertFalse(Files.exists(paths(transaction).backup()));
@@ -280,6 +280,28 @@ public class WorldReplacementBootstrapTest {
} }
} }
@Test
public void skipsJournalStagedAgainstAnotherLevelRootInsteadOfAborting() throws Exception {
Transaction transaction = stagedTransaction(Phase.CLEANUP_PENDING, true, "original");
Path otherLevelRoot = Files.createDirectories(serverRoot.resolve("renamed-world"));
WorldReplacementBootstrap.ReconcileResult result = WorldReplacementBootstrap.reconcile(
dataDirectory,
otherLevelRoot,
bukkitConfiguration,
ignored -> {
}
);
assertEquals(1, result.skipped());
assertEquals(0, result.published());
assertEquals(0, result.rolledBack());
assertEquals(0, result.retained());
assertTrue(Files.exists(dataDirectory
.resolve(WorldReplacementJournal.DIRECTORY_NAME)
.resolve(transaction.id() + ".properties")));
}
private WorldReplacementBootstrap.ReconcileResult reconcile() throws Exception { private WorldReplacementBootstrap.ReconcileResult reconcile() throws Exception {
return WorldReplacementBootstrap.reconcile( return WorldReplacementBootstrap.reconcile(
dataDirectory, dataDirectory,
@@ -108,6 +108,23 @@ public class WorldReplacementFilesystemTest {
assertFalse(Files.exists(paths.backup())); assertFalse(Files.exists(paths.backup()));
} }
@Test
public void rejectsUnmigratedRetainedWorldAtAdmission() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("admit-missing-paper-metadata", TRANSACTION_ID);
Files.createDirectories(paths.target());
Files.writeString(paths.target().resolve("original.txt"), "original");
IOException failure = assertThrows(
IOException.class,
() -> WorldReplacementFilesystem.requireExistingTarget(paths)
);
assertTrue(failure.getMessage().contains("missing Paper world metadata"));
assertTrue(failure.getMessage().contains("load the world once on this server"));
assertFalse(Files.exists(paths.stage()));
assertFalse(Files.exists(paths.backup()));
}
@Test @Test
public void publishesReplacementWithoutCreatingBackupForAbsentTarget() throws Exception { public void publishesReplacementWithoutCreatingBackupForAbsentTarget() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-absent", TRANSACTION_ID); WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-absent", TRANSACTION_ID);
@@ -8,7 +8,9 @@ import java.io.ByteArrayOutputStream;
import java.io.PrintStream; import java.io.PrintStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -25,7 +27,7 @@ public class IrisLoggingTest {
@Test @Test
public void contextualReportPrintsFullStacktraceWithBoundPlatform() { public void contextualReportPrintsFullStacktraceWithBoundPlatform() {
IrisPlatform platform = mock(IrisPlatform.class); IrisPlatform platform = mock(IrisPlatform.class, CALLS_REAL_METHODS);
IrisPlatforms.bind(platform); IrisPlatforms.bind(platform);
IllegalStateException failure = new IllegalStateException("outer", new IllegalArgumentException("inner")); IllegalStateException failure = new IllegalStateException("outer", new IllegalArgumentException("inner"));
ByteArrayOutputStream output = new ByteArrayOutputStream(); ByteArrayOutputStream output = new ByteArrayOutputStream();
@@ -38,10 +40,29 @@ public class IrisLoggingTest {
} }
verify(platform).log(LogLevel.ERROR, "Runtime world creation failed."); verify(platform).log(LogLevel.ERROR, "Runtime world creation failed.");
verify(platform).reportError("Runtime world creation failed.", failure);
verify(platform).reportError(failure); verify(platform).reportError(failure);
String text = output.toString(StandardCharsets.UTF_8); String text = output.toString(StandardCharsets.UTF_8);
assertTrue(text.contains("IllegalStateException")); assertTrue(text.contains("IllegalStateException"));
assertTrue(text.contains("IllegalArgumentException")); assertTrue(text.contains("IllegalArgumentException"));
assertTrue(text.contains("inner")); assertTrue(text.contains("inner"));
} }
@Test
public void contextualReportHonorsPlatformOverrideSuppression() {
IrisPlatform platform = mock(IrisPlatform.class);
IrisPlatforms.bind(platform);
IllegalStateException failure = new IllegalStateException("suppressed");
ByteArrayOutputStream output = new ByteArrayOutputStream();
PrintStream originalErr = System.err;
System.setErr(new PrintStream(output, true, StandardCharsets.UTF_8));
try {
IrisLogging.reportError("Throttled failure.", failure);
} finally {
System.setErr(originalErr);
}
verify(platform).reportError("Throttled failure.", failure);
assertFalse(output.toString(StandardCharsets.UTF_8).contains("suppressed"));
}
} }
@@ -19,6 +19,7 @@ public final class StubTileData extends TileData {
private static final Gson GSON = new GsonBuilder() private static final Gson GSON = new GsonBuilder()
.disableHtmlEscaping() .disableHtmlEscaping()
.setStrictness(Strictness.LENIENT) .setStrictness(Strictness.LENIENT)
.setObjectToNumberStrategy(com.google.gson.ToNumberPolicy.LONG_OR_DOUBLE)
.create(); .create();
private final String blockKey; private final String blockKey;
@@ -32,6 +32,24 @@ public class StubTileDataTest {
assertEquals("minecraft:chests/ancient_city", decoded.getProperties().get("LootTable")); assertEquals("minecraft:chests/ancient_city", decoded.getProperties().get("LootTable"));
} }
@Test
public void modernIntegralPropertiesReencodeByteIdentical() throws Exception {
KMap<String, Object> properties = new KMap<>();
properties.put("LootTable", "minecraft:chests/ancient_city");
properties.put("LootTableSeed", -7205759403792793599L);
StubTileData original = StubTileData.fromProperties(
StubPlatform.blockStateForTest("minecraft:chest[facing=north]"), properties);
byte[] encoded = encode(original::toBinary);
StubTileData decoded;
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded))) {
decoded = StubTileData.read(input);
}
assertArrayEquals(encoded, encode(decoded::toBinary));
assertEquals(-7205759403792793599L, decoded.getProperties().get("LootTableSeed"));
}
@Test @Test
public void legacyLootablePayloadPreservesItsCompleteFrame() throws Exception { public void legacyLootablePayloadPreservesItsCompleteFrame() throws Exception {
byte[] payload = encode(output -> { byte[] payload = encode(output -> {
@@ -97,6 +97,11 @@ public final class IrisLogging {
inner.printStackTrace(System.err); inner.printStackTrace(System.err);
} }
if (IrisPlatforms.isBound()) {
IrisPlatforms.get().reportError(message, cause);
return;
}
reportError(cause); reportError(cause);
cause.printStackTrace(System.err); cause.printStackTrace(System.err);
} }
@@ -195,4 +195,17 @@ public interface IrisPlatform {
* Hands a throwable to the host's error reporting. Safe from any thread; must not rethrow. * Hands a throwable to the host's error reporting. Safe from any thread; must not rethrow.
*/ */
void reportError(Throwable error); void reportError(Throwable error);
/**
* Contextual variant of {@link #reportError(Throwable)}. The platform owns trace emission so
* each adapter prints exactly one copy and its own suppression (throttles, debug gates) is
* honored; the default preserves the historical behavior of reporting plus an unconditional
* stack trace on stderr.
*/
default void reportError(String context, Throwable error) {
reportError(error);
if (error != null) {
error.printStackTrace(System.err);
}
}
} }