Full audit fixpass: 93 defect fixes, 14 perf wins, vestigial cleanup

This commit is contained in:
Brian Neumann-Fopiano
2026-08-13 01:01:01 -04:00
parent 13e02ecd2f
commit 14b6280668
215 changed files with 4604 additions and 2407 deletions
@@ -55,6 +55,7 @@ import it.unimi.dsi.fastutil.objects.Object2IntMap;
import it.unimi.dsi.fastutil.shorts.ShortList;
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.agent.builder.ResettableClassFileTransformer;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;
import net.minecraft.core.BlockPos;
@@ -172,6 +173,7 @@ public class NMSBinding implements INMSBinding {
private final BlockData AIR = Material.AIR.createBlockData();
private final AtomicCache<MCAIdMap<net.minecraft.world.level.biome.Biome>> biomeMapCache = new AtomicCache<>();
private final AtomicBoolean injected = new AtomicBoolean();
private volatile ResettableClassFileTransformer serverLevelTransformer;
private final AtomicCache<MCAIdMapper<BlockState>> registryCache = new AtomicCache<>();
private final AtomicCache<MCAPalette<BlockState>> globalCache = new AtomicCache<>();
private final AtomicCache<RegistryAccess> registryAccess = new AtomicCache<>();
@@ -1523,7 +1525,7 @@ public class NMSBinding implements INMSBinding {
}
try {
IrisLogging.info("Injecting Bukkit");
new AgentBuilder.Default()
serverLevelTransformer = new AgentBuilder.Default()
.disableClassFormatChanges()
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
.type(ElementMatchers.is(ServerLevel.class))
@@ -1546,6 +1548,17 @@ public class NMSBinding implements INMSBinding {
} catch (Throwable e) {
IrisLogging.error(C.RED + "Failed to inject Bukkit");
e.printStackTrace();
// The ServerLevel transformer may already be installed when the ChunkAccess
// redefine throws; remove it or a retry would stack a second one and orphan
// this one permanently.
ResettableClassFileTransformer partial = serverLevelTransformer;
serverLevelTransformer = null;
if (partial != null) {
try {
partial.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION);
} catch (Throwable ignored) {
}
}
return false;
}
}
@@ -1562,6 +1575,24 @@ public class NMSBinding implements INMSBinding {
}
}
@Override
public void uninjectBukkit() {
synchronized (injected) {
ResettableClassFileTransformer transformer = serverLevelTransformer;
serverLevelTransformer = null;
injected.set(false);
if (transformer == null) {
return;
}
try {
transformer.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION);
} catch (Throwable e) {
IrisLogging.error(C.RED + "Failed to remove ServerLevel injection");
e.printStackTrace();
}
}
}
@Override
public KMap<Material, List<BlockProperty>> getBlockProperties() {
KMap<Material, List<BlockProperty>> states = new KMap<>();
@@ -1624,24 +1655,56 @@ public class NMSBinding implements INMSBinding {
if (dimensionKey == null)
return;
// This advice is inlined into every ServerLevel construction on the server. Until a
// world is proven Iris-owned, every failure must fail OPEN (keep the vanilla stem):
// Iris being unloaded or half-loaded must never break other plugins' world creation.
String levelId;
ClassLoader pluginClassLoader;
Class<?> generatorType;
Class<?> stagingType;
try {
String levelId = dimensionKey.identifier().getPath();
levelId = dimensionKey.identifier().getPath();
if (levelId == null || levelId.isBlank()) {
return;
}
ClassLoader pluginClassLoader = Bukkit.getPluginManager().getPlugin("Iris").getClass().getClassLoader();
Class<?> generatorType = Class.forName("art.arcane.iris.engine.platform.PlatformChunkGenerator", true, pluginClassLoader);
org.bukkit.plugin.Plugin irisPlugin = Bukkit.getPluginManager().getPlugin("Iris");
if (irisPlugin == null) {
return;
}
pluginClassLoader = irisPlugin.getClass().getClassLoader();
generatorType = Class.forName("art.arcane.iris.engine.platform.PlatformChunkGenerator", true, pluginClassLoader);
stagingType = Class.forName("art.arcane.iris.core.lifecycle.WorldLifecycleStaging", true, pluginClassLoader);
} catch (Throwable ignored) {
// Iris absent or half-loaded: fail OPEN for a world we cannot prove ours.
return;
}
// From here Iris is present and its classes resolve; a failure resolving the
// staged generator must fail LOUD — silently handing a possibly-staged Iris world
// the vanilla stem corrupts generation.
ChunkGenerator gen = null;
try {
Object generator = generatorType.isInstance(constructorGenerator) ? constructorGenerator : null;
if (generator == null) {
generator = Class.forName("art.arcane.iris.core.lifecycle.WorldLifecycleStaging", true, pluginClassLoader)
generator = stagingType
.getDeclaredMethod("consumeStemGenerator", String.class)
.invoke(null, levelId);
}
if (!(generator instanceof ChunkGenerator gen) || !gen.getClass().getPackageName().startsWith("art.arcane.iris")) {
return;
if (generator instanceof ChunkGenerator owned && owned.getClass().getPackageName().startsWith("art.arcane.iris")) {
gen = owned;
}
} catch (Throwable e) {
throw new RuntimeException("Iris failed to resolve the staged world generator",
e instanceof InvocationTargetException ex ? ex.getCause() : e);
}
if (gen == null) {
return;
}
// Past the ownership gate the world is Iris-owned; silently handing back the
// vanilla stem would corrupt generation, so failures from here rethrow.
try {
Object bindings = Class.forName("art.arcane.iris.core.nms.INMS", true, pluginClassLoader)
.getDeclaredMethod("get")
.invoke(null);
+3
View File
@@ -14,6 +14,9 @@ dependencies {
transitive = false
}
compileOnly(libs.paper.api)
// LogFilterSVC (constructed in Iris.enable's explicit service list) implements the
// log4j-core Filter interface, so its constructor reference needs the type resolvable.
compileOnly(libs.log4j.core)
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.paper.api)
@@ -51,9 +51,27 @@ import art.arcane.iris.core.link.MultiverseCoreLink;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.gui.BukkitGuiHost;
import art.arcane.iris.core.gui.PregeneratorJob;
import art.arcane.iris.core.service.BoardSVC;
import art.arcane.iris.core.service.CommandSVC;
import art.arcane.iris.core.service.DatapackStructureScopeSVC;
import art.arcane.iris.core.service.EditSVC;
import art.arcane.iris.core.service.EntityRiseSVC;
import art.arcane.iris.core.service.ExternalDataSVC;
import art.arcane.iris.core.service.GlobalCacheSVC;
import art.arcane.iris.core.service.IrisApiEventSVC;
import art.arcane.iris.core.service.IrisEngineSVC;
import art.arcane.iris.core.service.IrisIntegrationService;
import art.arcane.iris.core.service.IrisProtocolService;
import art.arcane.iris.core.service.IrisTerrainSVC;
import art.arcane.iris.core.service.JigsawStudioService;
import art.arcane.iris.core.service.LogFilterSVC;
import art.arcane.iris.core.service.ObjectSVC;
import art.arcane.iris.core.service.ObjectStudioSaveService;
import art.arcane.iris.core.service.PreservationSVC;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.core.service.TreeFellerSVC;
import art.arcane.iris.core.service.TreeSVC;
import art.arcane.iris.core.service.WandSVC;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.EnginePanic;
import art.arcane.iris.engine.framework.BlockEditAccess;
@@ -80,7 +98,6 @@ import art.arcane.volmlib.util.hud.HudBossBarLane;
import art.arcane.volmlib.util.hud.HudSlotService;
import art.arcane.volmlib.util.io.IO;
import art.arcane.volmlib.util.io.InstanceState;
import art.arcane.volmlib.util.io.JarScanner;
import art.arcane.volmlib.util.math.M;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.common.misc.Bindings;
@@ -98,7 +115,6 @@ import org.bukkit.Bukkit;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.block.data.BlockData;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
@@ -115,9 +131,9 @@ import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.PrintWriter;
import java.lang.annotation.Annotation;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
@@ -165,6 +181,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private volatile IrisPapiListener papiListener;
private volatile IrisPapiState papiState;
private KMap<Class<? extends IrisService>, IrisService> services;
// Copy-on-write: mutated on the main thread during enable() and iterated by the JVM
// shutdown-hook thread during teardown; a plain list would CME and abort the teardown.
private final List<IrisService> enabledServices = new CopyOnWriteArrayList<>();
private final IrisWorldGeneratorResolver generatorResolver = new IrisWorldGeneratorResolver(this);
private final BukkitWorldReconciler worldReconciler = new BukkitWorldReconciler(this);
private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this);
@@ -206,28 +225,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
}
private static <T> KList<T> initialize(String s, Class<T> requiredType) {
JarScanner js = new JarScanner(instance.getJarFile(), s);
KList<T> v = new KList<>();
J.attempt(js::scan);
for (Class<?> i : js.getClasses()) {
if (!isConcreteImplementation(i, requiredType)) {
continue;
}
try {
v.add(requiredType.cast(i.getDeclaredConstructor().newInstance()));
} catch (Throwable ex) {
Iris.warn("Skipped class initialization for %s: %s%s",
i.getName(),
ex.getClass().getSimpleName(),
ex.getMessage() == null ? "" : " - " + ex.getMessage());
Iris.reportError(ex);
}
}
return v;
}
static boolean isConcreteImplementation(Class<?> candidate, Class<?> requiredType) {
int modifiers = candidate.getModifiers();
return requiredType.isAssignableFrom(candidate)
@@ -235,27 +232,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
&& !Modifier.isAbstract(modifiers);
}
public static KList<Class<?>> getClasses(String s, Class<? extends Annotation> slicedClass) {
JarScanner js = new JarScanner(instance.getJarFile(), s);
KList<Class<?>> v = new KList<>();
J.attempt(js::scan);
for (Class<?> i : js.getClasses()) {
if (slicedClass == null || i.isAnnotationPresent(slicedClass)) {
try {
v.add(i);
} catch (Throwable ex) {
Iris.warn("Skipped class discovery entry for %s: %s%s",
i.getName(),
ex.getClass().getSimpleName(),
ex.getMessage() == null ? "" : " - " + ex.getMessage());
Iris.reportError(ex);
}
}
}
return v;
}
public static void sq(Runnable r) {
synchronized (syncJobs) {
syncJobs.queue(r);
@@ -538,7 +514,21 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
}
private void enable() {
/**
* @return false when the bootstrap was aborted (unsupported server version); the caller
* must bail out of onEnable without touching any further setup.
*/
private boolean enable() {
if (!INMS.isBound()) {
Throwable bindFailure = INMS.bindFailure();
Iris.error("Iris cannot start: " + (bindFailure == null
? "no NMS binding is available for this server version."
: bindFailure.getMessage()));
// Deferred one tick: disablePlugin from inside onEnable re-enters onDisable
// synchronously and the loader then continues registering the half-enabled plugin.
J.s(() -> Bukkit.getPluginManager().disablePlugin(this), 1);
return false;
}
alreadyDrained.set(false);
servicesDisabled.set(false);
sharedRuntimeClosed.set(false);
@@ -551,25 +541,50 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
setupAudience();
BukkitPlatform.hostHud(new HudSlotService(this), new HudBossBarLane());
Bindings.setupSentry();
initialize("art.arcane.iris.core.service", IrisService.class).forEach((i) -> {
// Explicit, ordered service list: the previous reflective jar scan gave hash-ordered
// enable/disable and paid a full-jar class sweep at boot. Infrastructure first,
// engine/world services next, commands last.
List<IrisService> orderedServices = List.of(
new PreservationSVC(),
new GlobalCacheSVC(),
new LogFilterSVC(),
new ExternalDataSVC(),
new EditSVC(),
new ObjectSVC(),
new ObjectStudioSaveService(),
new JigsawStudioService(),
new StudioSVC(),
new DatapackStructureScopeSVC(),
new IrisEngineSVC(),
new IrisTerrainSVC(),
new TreeSVC(),
new TreeFellerSVC(),
new EntityRiseSVC(),
new WandSVC(),
new BoardSVC(),
new IrisIntegrationService(),
new IrisProtocolService(),
new IrisApiEventSVC(),
new CommandSVC()
);
for (IrisService i : orderedServices) {
Class<? extends IrisService> serviceType = i.getClass().asSubclass(IrisService.class);
services.put(serviceType, i);
IrisServices.register(serviceType, i);
});
}
IrisServices.register(BlockEditAccess.class, services.get(EditSVC.class));
IrisServices.register(PreservationRegistry.class, services.get(PreservationSVC.class));
IO.delete(new File("iris"));
compat = IrisCompat.configured(getDataFile("compat.json"));
IrisServices.register(IrisCompat.class, compat);
ServerConfigurator.configure();
IrisToolbelt.applyPregenPerformanceProfile();
StartupValidationOutcome datapackValidation = DatapackIngestService.validateOnStartup();
if (datapackValidation == StartupValidationOutcome.READY) {
generatorResolver.validateAllPacks();
}
IrisSafeguard.execute();
getSender().setTag(getTag());
splash();
// A cosmetic banner must never abort the bootstrap.
J.attempt(this::splash);
IrisSafeguard.printReports();
IrisSafeguard.printFooter();
tickets = new ChunkTickets();
@@ -594,20 +609,54 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
// packs through cache/temp on an async thread, and a concurrent delete of that folder
// truncated pack imports mid-copy (partial packs/<key> without dimensions/).
IO.delete(getTemp());
services.values().forEach(IrisService::onEnable);
services.values().forEach(this::registerListener);
// One throwing service must not abort the bootstrap: the steps after this loop
// (listeners, shutdown hook, replacement journals) are the safety-critical ones.
// Only services that actually enabled get listeners and a later onDisable.
enabledServices.clear();
IrisService firstFailedService = null;
for (IrisService service : orderedServices) {
try {
service.onEnable();
enabledServices.add(service);
} catch (Throwable e) {
if (firstFailedService == null) {
firstFailedService = service;
}
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 {
service.onDisable();
} catch (Throwable cleanup) {
Iris.reportError("Failed to clean up partially enabled " + service.getClass().getSimpleName() + ".", cleanup);
}
}
}
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) {
try {
registerListener(service);
} catch (Throwable e) {
Iris.reportError("Failed to register listener for " + service.getClass().getSimpleName() + ".", e);
}
}
addShutdownHook();
pendingWorldReplacements.processPendingStartupReplacements();
pendingWorldDeletes.processPendingStartupWorldDeletes();
WorldLifecycleService.get();
WorldRuntimeControlService.get();
if (J.isFolia() && IrisStartupValidation.isReady()) {
J.s(() -> worldReconciler.checkForBukkitWorlds(s -> true), 1);
}
J.s(() -> {
pendingWorldReplacements.verifyLoadedPublishedWorlds();
pendingWorldReplacements.captureVanillaLevelContext();
// Off-main: the verify body takes the replacement-manager monitor and SHA-hashes
// whole pack trees; neither belongs on the tick thread.
J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds);
J.a(this::bstats);
J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60);
J.sr(this::tickQueue, 0);
@@ -620,20 +669,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
worldReconciler.checkForBukkitWorlds(s -> true);
}
IrisToolbelt.retainMantleDataForSlice(String.class.getCanonicalName());
IrisToolbelt.retainMantleDataForSlice(BlockData.class.getCanonicalName());
// The mantle stores block values as PlatformBlockState, so a BlockData retention can never
// match a slice type; the block-state slice is deliberately never retainable (regenerable, huge).
IrisToolbelt.retainMantleDataForSlice(TreeBlockMaterial.class.getCanonicalName());
});
return true;
}
public void addShutdownHook() {
if (shutdownHook != null) {
try {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
} catch (IllegalStateException ex) {
Iris.debug("Skipping shutdown hook replacement because JVM shutdown is already in progress.");
return;
}
}
removeShutdownHook();
shutdownHook = new Thread(() -> teardownRuntime("shutdown-hook", 30L), "Iris-ShutdownHook");
try {
Runtime.getRuntime().addShutdownHook(shutdownHook);
@@ -642,6 +686,24 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
}
/**
* The static-field guard in addShutdownHook is dead across a plugin reload (fresh
* classloader, fresh static), so onDisable must deregister the hook explicitly or each
* reload stacks another hook pinning the previous plugin classloader for the JVM's life.
*/
public void removeShutdownHook() {
Thread hook = shutdownHook;
shutdownHook = null;
if (hook == null) {
return;
}
try {
Runtime.getRuntime().removeShutdownHook(hook);
} catch (IllegalStateException ignored) {
Iris.debug("Skipping shutdown hook removal because JVM shutdown is already in progress.");
}
}
public BukkitWorldReconciler worldReconciler() {
return worldReconciler;
}
@@ -692,16 +754,20 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisStartupValidation.begin();
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
enable();
boolean enabled = enable();
if (!enabled) {
return;
}
BukkitGuiHost.install();
// super.onEnable() already registers this instance as a listener.
super.onEnable();
Bukkit.getPluginManager().registerEvents(this, this);
}
public void onDisable() {
teardownPapi();
if (IrisSafeguard.isForceShutdown()) return;
teardownRuntime("onDisable", 30L);
removeShutdownHook();
J.attempt(() -> INMS.get().uninjectBukkit());
if (BukkitPlatform.hasHud()) {
BukkitPlatform.hudSlots().shutdown();
BukkitPlatform.hudLanes().shutdown();
@@ -710,12 +776,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
configHotloadEngine.clear();
configHotloadEngine = null;
}
J.cancelPluginTasks();
HandlerList.unregisterAll((Plugin) this);
runPostShutdown();
// super.onDisable() cancels plugin tasks and unregisters every listener.
super.onDisable();
J.attempt(new JarScanner(instance.getJarFile(), "", false)::scanAll);
IrisPlatforms.unbind();
}
@@ -753,7 +816,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
if (services != null && servicesDisabled.compareAndSet(false, true)) {
for (IrisService service : services.values()) {
// Only services whose onEnable actually completed; disabling a service that
// never initialized runs teardown against uninitialized state.
for (IrisService service : enabledServices) {
try {
service.onDisable();
} catch (Throwable e) {
@@ -768,6 +833,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
J.attempt(MultiBurst.burst::close);
J.attempt(MultiBurst.ioBurst::close);
clearQueues();
IrisServices.clear();
}
}
@@ -169,7 +169,7 @@ public final class IrisWorldGeneratorResolver {
IrisDimension dimension = pack.isDirectory() ? IrisData.get(pack).getDimensionLoader().load(id) : null;
if (dimension == null) dimension = IrisData.loadAnyDimension(id, null);
if (dimension == null) {
File packsRoot = IrisPlatforms.get().dataFolderNoCreate(StudioSVC.WORKSPACE_NAME);
File packsRoot = IrisPlatforms.get().packsFolderNoCreate();
if (PackDownloader.isPackPresent(packsRoot, id)) {
Iris.error("Pack '" + id + "' exists at " + new File(packsRoot, id).getPath()
+ " but its dimension failed to load; not redownloading. Fix or delete the pack folder.");
@@ -254,7 +254,10 @@ public final class PendingWorldReplacementManager implements Listener {
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldLoad(WorldLoadEvent event) {
World world = event.getWorld();
J.s(() -> verifyPublishedWorldIfPending(world), 1);
// One tick to let the load settle, then verify off-main: the body takes the manager
// monitor (held across pack staging by async threads) and SHA-hashes the whole pack
// tree — blocking the main thread on either froze the server.
J.s(() -> J.a(() -> verifyPublishedWorldIfPending(world)), 1);
}
private synchronized void verifyPublishedWorldIfPending(World world) {
@@ -506,10 +509,46 @@ public final class PendingWorldReplacementManager implements Listener {
}
}
/**
* Captures the primary level context on the main thread at startup. resolveEffectiveSeed
* reads this snapshot so staging never blocks on a main-thread hop while holding the
* manager monitor — that inversion froze the server for the full 30s hop timeout whenever
* another world loaded during staging. The context is stable for the process lifetime:
* slot replacements only commit across a restart.
*/
public void captureVanillaLevelContext() {
try {
vanillaLevelContext = new VanillaLevelContext(
WorldIdentity.resolve(NamespacedKey.minecraft("overworld"))
.orElseThrow(() -> new IllegalStateException("The configured primary world is not loaded."))
.getSeed(),
Iris.instance.getServer().getAllowNether(),
Iris.instance.getServer().getAllowEnd()
);
} catch (Throwable failure) {
Iris.debug("Could not capture the primary level context yet: " + detail(failure));
}
}
private static long resolveEffectiveSeed(SlotKind slotKind, long requestedSeed) throws IOException {
if (slotKind == SlotKind.IRIS_MANAGED) {
return requestedSeed;
}
VanillaLevelContext context = vanillaLevelContext;
if (context == null) {
context = resolveVanillaLevelContext();
vanillaLevelContext = context;
}
if (slotKind == SlotKind.VANILLA_NETHER && !context.allowNether()) {
throw new IOException("allow-nether must be true before the vanilla Nether can be replaced.");
}
if (slotKind == SlotKind.VANILLA_END && !context.allowEnd()) {
throw new IOException("Bukkit allow-end must be true before the vanilla End can be replaced.");
}
return context.seed();
}
private static VanillaLevelContext resolveVanillaLevelContext() throws IOException {
CompletableFuture<VanillaLevelContext> contextFuture = J.sfut(() -> new VanillaLevelContext(
WorldIdentity.resolve(NamespacedKey.minecraft("overworld"))
.orElseThrow(() -> new IllegalStateException("The configured primary world is not loaded."))
@@ -521,14 +560,7 @@ public final class PendingWorldReplacementManager implements Listener {
throw new IOException("Could not schedule primary level-seed resolution.");
}
try {
VanillaLevelContext context = contextFuture.get(30L, TimeUnit.SECONDS);
if (slotKind == SlotKind.VANILLA_NETHER && !context.allowNether()) {
throw new IOException("allow-nether must be true before the vanilla Nether can be replaced.");
}
if (slotKind == SlotKind.VANILLA_END && !context.allowEnd()) {
throw new IOException("Bukkit allow-end must be true before the vanilla End can be replaced.");
}
return context.seed();
return contextFuture.get(30L, TimeUnit.SECONDS);
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IOException("Primary level-seed resolution was interrupted.", failure);
@@ -580,6 +612,8 @@ public final class PendingWorldReplacementManager implements Listener {
}
}
private static volatile VanillaLevelContext vanillaLevelContext;
private record VanillaLevelContext(long seed, boolean allowNether, boolean allowEnd) {
}
@@ -394,7 +394,7 @@ public class CommandDeveloper implements DirectorExecutor {
}
}
@Director(name = "goldenhash", aliases = {"gold"}, description = "Generate chunks into buffers (no world writes) and hash blocks+biomes; captures a golden file or verifies against an existing one. Resets mantle in the scanned area - use on disposable test worlds.", descriptionKey = "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden", origin = DirectorOrigin.BOTH)
@Director(name = "goldenhash", aliases = {"gold"}, description = "Generate chunks into buffers (no world writes) and hash blocks+biomes; captures a golden file or verifies against an existing one. Deletes the world's entire mantle - use on disposable test worlds.", descriptionKey = "iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden", origin = DirectorOrigin.BOTH)
public void goldenhash(
@Param(description = "The world to scan", descriptionKey = "iris.director.commanddeveloper.param.world_scan", contextual = true)
World world,
@@ -404,7 +404,7 @@ public class CommandDeveloper implements DirectorExecutor {
int centerX,
@Param(name = "center-z", description = "Center chunk Z", descriptionKey = "iris.director.commanddeveloper.param.center_chunk_z_2", defaultValue = "0")
int centerZ,
@Param(name = "reset-mantle", description = "Delete mantle data in the scan area first for full regeneration from scratch", descriptionKey = "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch", defaultValue = "true")
@Param(name = "reset-mantle", description = "Delete the world's entire mantle folder first for full regeneration from scratch", descriptionKey = "iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch", defaultValue = "true")
boolean resetMantle,
@Param(name = "threads", description = "Concurrent chunk generations; 1 = strictly serial for order-dependence testing", descriptionKey = "iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing", defaultValue = "8")
int threads,
@@ -705,7 +705,7 @@ public class CommandIris implements DirectorExecutor {
public void download(
@Param(name = "pack", description = "The pack to download", descriptionKey = "iris.director.commandiris.param.pack_download", aliases = "project")
String pack,
@Param(name = "branch", description = "The branch to download from", descriptionKey = "iris.director.commandiris.param.branch_download_from", defaultValue = "stable")
@Param(name = "branch", description = "The branch to download from", descriptionKey = "iris.director.commandiris.param.branch_download_from", defaultValue = PackDownloader.DEFAULT_BRANCH)
String branch,
@Param(name = "overwrite", description = "Whether or not to overwrite the pack with the downloaded one", descriptionKey = "iris.director.commandiris.param.whether_not_overwrite_pack_with_downloaded_one", aliases = "force", defaultValue = "false")
boolean overwrite
@@ -803,7 +803,7 @@ public class CommandIris implements DirectorExecutor {
});
guardUnloadCompletion(sequence, terminalTimeout, world.getName())
.whenComplete((unloaded, throwable) -> {
IrisToolbelt.endWorldMaintenance(world, "world-unload");
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
lease.close();
Runnable response = () -> reportUnloadResult(responseSender, world, unloaded, throwable);
if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
@@ -812,7 +812,7 @@ public class CommandIris implements DirectorExecutor {
J.s(response);
});
} catch (Exception e) {
IrisToolbelt.endWorldMaintenance(world, "world-unload");
IrisToolbelt.endWorldMaintenance(world, "world-unload", true);
lease.close();
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e);
@@ -37,7 +37,6 @@ import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.engine.object.IrisObjectPlacementScaleInterpolator;
import art.arcane.iris.engine.object.IrisObjectRotation;
import art.arcane.iris.engine.object.StudioMode;
import art.arcane.iris.engine.object.TileData;
import art.arcane.iris.platform.bukkit.BukkitBlockState;
import art.arcane.iris.spi.PlatformBlockState;
@@ -144,7 +143,8 @@ public class CommandObject implements DirectorExecutor {
return;
}
hostDimension.setStudioMode(StudioMode.OBJECT_BUFFET);
// ObjectStudioActivation carries the buffet state; mutating the shared cached
// IrisDimension here leaked OBJECT_BUFFET into later pack exports of this dimension.
ObjectStudioActivation.activate(hostDimension.getLoadKey());
ObjectStudioActivation.setSources(hostDimension.getLoadKey(), sources);
@@ -637,6 +637,7 @@ public class CommandObject implements DirectorExecutor {
} catch (IOException e) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_OBJECT_FAILED_SAVE_OBJECT_BECAUSE_IOEXCEPTION, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
Iris.reportError(e);
return;
}
sender().playSound(Sound.BLOCK_ENCHANTMENT_TABLE_USE, 1f, 1.5f);
@@ -42,9 +42,18 @@ import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.iris.core.localization.BukkitRuntimeMessages;
@Director(name = "pack", aliases = {"pk"}, description = "Pack validation and maintenance", descriptionKey = "iris.director.commandpack.director.pack_validation_maintenance")
public class CommandPack implements DirectorExecutor {
/**
* Director treats a blank defaultValue as "required", which made the all-packs branch
* unreachable from chat. The "*" sentinel keeps the parameter genuinely optional; a blank
* value (the documented "pack=" escape hatch) still means every pack.
*/
static boolean wantsAllPacks(String pack) {
return pack == null || pack.isBlank() || "*".equals(pack.trim());
}
@Director(description = "Validate a pack (or all packs) and re-publish results", descriptionKey = "iris.director.commandpack.director.validate_pack_all_packs_re_publish_results", aliases = {"v"})
public void validate(
@Param(description = "The pack folder name to validate (leave empty for all)", descriptionKey = "iris.director.commandpack.param.pack_folder_name_validate_leave_empty_all", defaultValue = "")
@Param(description = "The pack folder name to validate, or * for every pack", descriptionKey = "iris.director.commandpack.param.pack_folder_name_validate_leave_empty_all", defaultValue = "*")
String pack
) {
VolmitSender s = sender();
@@ -54,7 +63,7 @@ public class CommandPack implements DirectorExecutor {
return;
}
if (pack == null || pack.isBlank()) {
if (wantsAllPacks(pack)) {
List<File> dirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
if (dirs.isEmpty()) {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_PACKS_VALIDATE));
@@ -196,11 +205,11 @@ public class CommandPack implements DirectorExecutor {
@Director(description = "Show cached validation status for a pack", descriptionKey = "iris.director.commandpack.director.show_cached_validation_status_pack", aliases = {"s"})
public void status(
@Param(description = "The pack folder name", descriptionKey = "iris.director.commandpack.param.pack_folder_name", defaultValue = "")
@Param(description = "The pack folder name, or * for every pack", descriptionKey = "iris.director.commandpack.param.pack_folder_name", defaultValue = "*")
String pack
) {
VolmitSender s = sender();
if (pack == null || pack.isBlank()) {
if (wantsAllPacks(pack)) {
if (PackValidationRegistry.snapshot().isEmpty()) {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_VALIDATION_RESULTS_RECORDED_RUN_IRIS_PACK_VALIDATE_FIRST));
return;
@@ -33,6 +33,7 @@ import org.bukkit.util.Vector;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
import art.arcane.iris.core.localization.ModdedCommandMessages;
import art.arcane.iris.core.localization.RuntimeUiMessages;
import art.arcane.volmlib.util.localization.MessageArgument;
@Director(name = "pregen", aliases = "pregenerate", description = "Pregenerate your Iris worlds!", descriptionKey = "iris.director.commandpregen.director.pregenerate_your_iris_worlds")
@@ -59,6 +60,12 @@ public class CommandPregen implements DirectorExecutor {
return;
}
if (PregeneratorJob.getInstance() != null) {
// Reject like the modded adapter does; never silently kill a running job.
sender().sendMessage(IrisLanguage.text(ModdedCommandMessages.IRIS_MODDED_COMMANDS_PREGENERATION_TASK_IS_ALREADY_RUNNING_STOP_IT_FIRST_WITH_IRIS));
return;
}
try {
if (sender().isPlayer() && access() == null) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_PREGEN_ENGINE_ACCESS_THIS_WORLD_IS_NULL));
@@ -328,35 +328,50 @@ public class CommandStudio implements DirectorExecutor {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD));
return;
}
if (radius <= 0 || radius > 2048) {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD));
sender().sendMessage("Radius must be between 1 and 2048 chunks.");
return;
}
var sender = sender();
var player = player();
Thread.ofVirtual()
.start(() -> {
int d = radius * 2;
KMap<String, AtomicInteger> data = new KMap<>();
// Everything acquired below is released in the finally: a throw mid-scan
// previously leaked the whole sampler ForkJoinPool, the self-rescheduling
// progress task and both HUD claims for the rest of the server's uptime.
MultiBurst multiBurst = null;
HudSlotClaim titleClaim = null;
HudSlotClaim barClaim = null;
int c = -1;
try {
engine.getDimension().getRegions().forEach(key -> data.put(key, new AtomicInteger(0)));
var multiBurst = new MultiBurst("Region Sampler");
var executor = multiBurst.burst(radius * radius);
multiBurst = new MultiBurst("Region Sampler");
var executor = multiBurst.burst(Math.min(radius * radius, 1 << 20));
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_GENERATING_DATA));
var loc = player.getLocation();
int totalTasks = d * d;
AtomicInteger completedTasks = new AtomicInteger(0);
HudSlotClaim titleClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.TITLE)));
HudSlotClaim barClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.ACTION_BAR, HudSurface.BOSS_BAR)));
titleClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.TITLE)));
barClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.ACTION_BAR, HudSurface.BOSS_BAR)));
HudSlotClaim finalTitleClaim = titleClaim;
HudSlotClaim finalBarClaim = barClaim;
AtomicLong lastResolveMs = new AtomicLong(0L);
int c = J.ar(() -> {
c = J.ar(() -> {
long now = System.currentTimeMillis();
if (now - lastResolveMs.get() >= 250L) {
lastResolveMs.set(now);
titleClaim.resolve();
barClaim.resolve();
finalTitleClaim.resolve();
finalBarClaim.resolve();
}
double jobProgress = (double) completedTasks.get() / totalTasks;
HudSurface barSurface = barClaim.granted();
HudSurface barSurface = finalBarClaim.granted();
sender.sendProgress(
jobProgress,
IrisLanguage.text(RuntimeUiMessages.FINDING_REGIONS),
titleClaim.granted(),
finalTitleClaim.granted(),
barSurface
);
if (barSurface == HudSurface.BOSS_BAR) {
@@ -372,15 +387,28 @@ public class CommandStudio implements DirectorExecutor {
completedTasks.incrementAndGet();
})).setOffset(loc.getBlockX(), loc.getBlockZ()).drain();
executor.complete();
multiBurst.close();
J.car(c);
titleClaim.release();
barClaim.release();
BukkitPlatform.hudLanes().hide(player, "iris:job");
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_DONE));
var loader = engine.getData().getRegionLoader();
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) {
Iris.reportError(e);
sender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD));
} finally {
if (c != -1) {
J.car(c);
}
if (titleClaim != null) {
titleClaim.release();
}
if (barClaim != null) {
barClaim.release();
BukkitPlatform.hudLanes().hide(player, "iris:job");
}
if (multiBurst != null) {
multiBurst.close();
}
}
});
}
@@ -437,7 +465,6 @@ public class CommandStudio implements DirectorExecutor {
KMap<InterpolationMethod, Double> interpolatorTimings = new KMap<>();
KMap<String, Double> generatorTimings = new KMap<>();
KMap<String, Double> biomeTimings = new KMap<>();
KMap<String, Double> regionTimings = new KMap<>();
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_CALCULATING_PERFORMANCE_METRICS_NOISE_GENERATORS));
@@ -561,23 +588,6 @@ public class CommandStudio implements DirectorExecutor {
fileText.add("");
for (String i : data.getRegionLoader().getPossibleKeys()) {
IrisRegion b = data.getRegionLoader().load(i);
double score = 0;
score += styleTimings.get(b.getLakeStyle().getStyle());
score += styleTimings.get(b.getRiverStyle().getStyle());
regionTimings.put(i, score);
}
fileText.add("Project Region Performance Impacts: ");
for (String i : regionTimings.sortKNumber()) {
fileText.add(i + ": " + regionTimings.get(i));
}
fileText.add("");
double m = 0;
for (double i : biomeTimings.v()) {
m += i;
@@ -589,12 +599,6 @@ public class CommandStudio implements DirectorExecutor {
}
mm /= generatorTimings.size();
m += mm;
double mmm = 0;
for (double i : regionTimings.v()) {
mmm += i;
}
mmm /= regionTimings.size();
m += mmm;
fileText.add("Average Score: " + m);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_SCORE, MessageArgument.untrusted("value", Form.duration(m, 0))));
@@ -763,9 +767,8 @@ public class CommandStudio implements DirectorExecutor {
}
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_CAPTURING_IGENDATA_FROM_NEARBY_CHUNKS, MessageArgument.untrusted("value", chunks.size())));
try {
File ff = Iris.instance.getDataFile("reports/" + M.ms() + ".txt");
PrintWriter pw = new PrintWriter(ff);
File ff = Iris.instance.getDataFile("reports/" + M.ms() + ".txt");
try (PrintWriter pw = new PrintWriter(ff)) {
pw.println("=== Iris Chunk Report ===");
pw.println("== General Info ==");
pw.println("Iris Version: " + Iris.instance.getDescription().getVersion());
@@ -820,7 +823,8 @@ public class CommandStudio implements DirectorExecutor {
}
}
regions = Objects.requireNonNull(new File(world.getWorldFolder().getPath() + "/region").list()).length;
String[] regionFiles = new File(world.getWorldFolder(), "region").list();
regions = regionFiles == null ? 0 : regionFiles.length;
pw.println();
pw.println("== World Info ==");
@@ -854,10 +858,9 @@ public class CommandStudio implements DirectorExecutor {
}
pw.println();
pw.close();
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_REPORTED, MessageArgument.untrusted("value", ff.getPath())));
} catch (FileNotFoundException e) {
} catch (Throwable e) {
e.printStackTrace();
Iris.reportError(e);
}
@@ -26,7 +26,10 @@ import art.arcane.iris.core.edit.BlockSignal;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.GenerationSessionException;
import art.arcane.iris.engine.framework.GenerationSessionLease;
import art.arcane.iris.engine.platform.EngineBukkitOps;
import art.arcane.iris.util.project.context.IrisContext;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.util.common.director.DirectorExecutor;
@@ -36,8 +39,10 @@ import art.arcane.volmlib.util.director.DirectorOrigin;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import art.arcane.iris.util.common.format.C;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.matter.MatterMarker;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.FluidCollisionMode;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
@@ -47,7 +52,6 @@ import org.bukkit.block.data.BlockData;
import org.bukkit.entity.Player;
import java.lang.reflect.Method;
import java.util.concurrent.atomic.AtomicInteger;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
@@ -172,35 +176,73 @@ public class CommandWhat implements DirectorExecutor {
});
}
// Matches ModdedWhatCommands.MAX_MARKERS so both loaders truncate identically.
private static final int MAX_MARKER_HITS = 8_192;
private static final int MARKER_SIGNAL_BATCH = 128;
@Director(description = "Show markers in chunk", descriptionKey = "iris.director.commandwhat.director.show_markers_chunk", origin = DirectorOrigin.PLAYER)
public void markers(@Param(description = "Marker name such as cave_floor or cave_ceiling", descriptionKey = "iris.director.commandwhat.param.marker_name_such_as_cave_floor_cave_ceiling") String marker) {
VolmitSender commandSender = sender();
Player player = player();
if (player == null) {
return;
}
// Chunk lookup plus the block signals both need the thread owning the player's chunk.
// Only the chunk coordinates need the owning thread; the 81-chunk mantle sweep loads
// tectonic plates from disk and must never run on the tick thread (the modded adapter
// already scans async, leased and capped — mirror it).
onPlayerThread(player, () -> {
World world = player.getWorld();
Chunk c = player.getLocation().getChunk();
int chunkX = c.getX();
int chunkZ = c.getZ();
if (IrisToolbelt.isIrisWorld(c.getWorld())) {
AtomicInteger v = new AtomicInteger(0);
if (!IrisToolbelt.isIrisWorld(world)) {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IRIS_WORLDS_ONLY_2));
return;
}
Engine engine = IrisToolbelt.access(world).getEngine();
for (int xxx = c.getX() - 4; xxx <= c.getX() + 4; xxx++) {
for (int zzz = c.getZ() - 4; zzz <= c.getZ() + 4; zzz++) {
IrisToolbelt.access(c.getWorld()).getEngine().getMantle().findMarkers(xxx, zzz, new MatterMarker(marker))
.convert((i) -> BukkitPlatform.toLocation(i, c.getWorld())).forEach((i) -> {
BlockSignal.of(i.getWorld(), i.getBlockX(), i.getBlockY(), i.getBlockZ(), 100);
v.incrementAndGet();
});
J.a(() -> {
KList<Location> hits = new KList<>();
MatterMarker matterMarker = new MatterMarker(marker);
try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_what_markers");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
scan:
for (int xxx = chunkX - 4; xxx <= chunkX + 4; xxx++) {
for (int zzz = chunkZ - 4; zzz <= chunkZ + 4; zzz++) {
for (Location i : engine.getMantle().findMarkers(xxx, zzz, matterMarker)
.convert((p) -> BukkitPlatform.toLocation(p, world))) {
hits.add(i);
if (hits.size() >= MAX_MARKER_HITS) {
break scan;
}
}
}
}
} catch (GenerationSessionException e) {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IRIS_WORLDS_ONLY_2));
return;
}
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_FOUND_NEARBY_MARKERS, MessageArgument.untrusted("value", v.get()), MessageArgument.untrusted("marker", marker)));
} else {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IRIS_WORLDS_ONLY_2));
}
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_FOUND_NEARBY_MARKERS, MessageArgument.untrusted("value", hits.size()), MessageArgument.untrusted("marker", marker)));
emitMarkerSignals(player, engine, hits, 0);
});
});
}
private void emitMarkerSignals(Player player, Engine engine, KList<Location> hits, int from) {
if (from >= hits.size() || !player.isOnline() || engine.isClosed() || engine.isClosing()) {
return;
}
int to = Math.min(from + MARKER_SIGNAL_BATCH, hits.size());
for (int i = from; i < to; i++) {
Location at = hits.get(i);
BlockSignal.of(at.getWorld(), at.getBlockX(), at.getBlockY(), at.getBlockZ(), 100);
}
J.s(() -> emitMarkerSignals(player, engine, hits, to), 1);
}
/**
* Runs the body on the thread owning the player, reporting when the hop cannot be scheduled.
*/
@@ -1,5 +1,6 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.IrisStartupValidation;
import art.arcane.iris.core.datapack.DatapackIngestService;
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
import art.arcane.iris.core.nms.DatapackStructureScopeResult;
@@ -29,13 +30,26 @@ public final class DatapackStructureScopeSVC implements IrisService {
scopeIndex = DatapackStructureScopeIndex.create(
DatapackIngestService.installedStructureScopeResources());
} catch (IOException e) {
throw new IllegalStateException(
"Iris could not establish ownership for installed datapack structure sets", e);
// A missing manifest entry is the condition validateOnStartup already classified as
// non-fatal; degrade to an empty scope and lock world creation instead of aborting
// the whole plugin bootstrap (which would skip listeners and journal reconciliation).
scopeIndex = DatapackStructureScopeIndex.create(null);
IrisLogging.error("Could not establish ownership for installed datapack structure sets; "
+ "datapack structure scoping is disabled until the manifest is repaired: " + e.getMessage());
IrisStartupValidation.markDatapacksInvalid(
"Iris could not establish ownership for installed datapack structure sets: " + e.getMessage());
}
for (World world : Bukkit.getWorlds()) {
IrisImportedStructureControl importedStructures = importedStructures(world);
if (!scopeIndex.isEmpty() || importedStructures != null && importedStructures.hasFrequencyOverrides()) {
applyScope(world, importedStructures);
try {
IrisImportedStructureControl importedStructures = importedStructures(world);
if (!scopeIndex.isEmpty() || importedStructures != null && importedStructures.hasFrequencyOverrides()) {
applyScope(world, importedStructures);
}
} catch (Throwable e) {
// Per-world containment: one world's scoping failure must not abort the
// service enable (and with it the whole bootstrap's containment contract).
IrisLogging.error("Could not scope datapack structures for world '" + world.getName() + "'.");
IrisLogging.reportError(e);
}
}
}
@@ -35,7 +35,6 @@ import org.bukkit.event.world.WorldUnloadEvent;
public class EditSVC implements IrisService, BlockEditAccess<World, BlockData, Biome> {
private KMap<World, BlockEditor> editors;
private int updateTaskId = -1;
public static boolean deletingWorld = false;
@Override
public void onEnable() {
@@ -84,7 +83,7 @@ public class EditSVC implements IrisService, BlockEditAccess<World, BlockData, B
if (editors == null) {
return;
}
if (editors.containsKey(e.getWorld()) && !deletingWorld) {
if (editors.containsKey(e.getWorld())) {
editors.remove(e.getWorld()).close();
}
}
@@ -22,7 +22,7 @@ public class IrisStartupOrderingTest {
@Test
public void externalDatapacksValidateBeforeDimensionPacks() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String enable = section(source, "private void enable()", "public void addShutdownHook()");
String enable = section(source, "private boolean enable()", "public void addShutdownHook()");
assertOrdered(enable,
"DatapackIngestService.validateOnStartup();",
@@ -0,0 +1,20 @@
package art.arcane.iris.core.commands;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.Assert.assertTrue;
public class CommandIrisDownloadDefaultTest {
@Test
public void downloadBranchParamDefaultsToTheCoreConstant() throws Exception {
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/commands/CommandIris.java"));
int branchParam = source.indexOf("name = \"branch\"");
assertTrue("CommandIris must declare the branch param", branchParam >= 0);
String declaration = source.substring(branchParam, source.indexOf(')', branchParam));
assertTrue("branch param default must be PackDownloader.DEFAULT_BRANCH, not a literal",
declaration.contains("defaultValue = PackDownloader.DEFAULT_BRANCH"));
}
}
@@ -0,0 +1,63 @@
package art.arcane.iris.core.commands;
import art.arcane.volmlib.util.director.compat.DirectorAnnotationCompatibility;
import art.arcane.volmlib.util.director.runtime.DirectorNodeDescriptor;
import art.arcane.volmlib.util.director.runtime.DirectorParameterDescriptor;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class CommandPackOptionalPackContractTest {
private static DirectorParameterDescriptor packParam(String method) throws Exception {
DirectorNodeDescriptor node = DirectorAnnotationCompatibility
.fromMethod(CommandPack.class.getDeclaredMethod(method, String.class))
.orElseThrow();
DirectorParameterDescriptor pack = node.getParameters().get(0);
assertEquals("pack", pack.getName());
return pack;
}
@Test
public void bareValidateAndStatusAreInvocableWithoutAPackArgument() throws Exception {
for (String method : List.of("validate", "status")) {
DirectorParameterDescriptor pack = packParam(method);
assertFalse(method + " must not require a pack", pack.isRequired());
assertFalse(method + " needs a non-blank default for Director to treat it as optional",
pack.getDefaultValue().isBlank());
}
}
@Test
public void cleanupAndRestoreStillRequireAPack() throws Exception {
for (String method : List.of("cleanup", "restore")) {
DirectorNodeDescriptor node = DirectorAnnotationCompatibility
.fromMethod(findMethod(method))
.orElseThrow();
DirectorParameterDescriptor pack = node.getParameters().get(0);
assertEquals("pack", pack.getName());
assertTrue(method + " must keep requiring a pack", pack.isRequired());
}
}
@Test
public void allPacksPredicateAcceptsSentinelBlankAndNull() {
assertTrue(CommandPack.wantsAllPacks(null));
assertTrue(CommandPack.wantsAllPacks(""));
assertTrue(CommandPack.wantsAllPacks(" "));
assertTrue(CommandPack.wantsAllPacks("*"));
assertFalse(CommandPack.wantsAllPacks("overworld"));
}
private static java.lang.reflect.Method findMethod(String name) {
for (java.lang.reflect.Method method : CommandPack.class.getDeclaredMethods()) {
if (method.getName().equals(name)) {
return method;
}
}
throw new AssertionError("CommandPack must declare " + name);
}
}
@@ -46,7 +46,10 @@ public final class IrisClientPregenState {
jobs.remove(jobId);
Long current = activeJobId;
if (current != null && current == jobId) {
activeJobId = jobs.keySet().stream().findFirst().orElse(null);
// Promote nothing: the server runs a single live job, so any remaining entry is
// dead or orphaned — and the access-ordered map's head is the STALEST entry,
// which is what the HUD used to promote.
activeJobId = null;
}
}
@@ -161,7 +161,9 @@ public final class NativeStructureVegetationClearer {
if (state.is(BlockTags.LOGS) || state.is(BlockTags.LEAVES)) {
return true;
}
String path = BuiltInRegistries.BLOCK.getKey(state.getBlock()).getPath();
// Field reads instead of a registry byValue map hit — this predicate runs per block
// probe on the hottest native-structure scans.
String path = state.getBlock().builtInRegistryHolder().key().identifier().getPath();
return path.endsWith("_log") || path.endsWith("_wood")
|| path.endsWith("_stem") || path.endsWith("_hyphae")
|| path.endsWith("_leaves");
@@ -815,10 +815,10 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
int localY = blockY & 15;
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
if (blocks.isAir(x, bufferY, z)) {
PlatformBlockState state = blocks.rawOrNull(x, bufferY, z);
if (state == null) {
continue;
}
PlatformBlockState state = blocks.getRaw(x, bufferY, z);
BlockState blockState = (BlockState) state.nativeHandle();
section.setBlockState(x, localY, z, blockState, false);
if (blockState.hasBlockEntity()) {
@@ -40,6 +40,14 @@ public final class ModdedBlockBuffer implements Hunk<PlatformBlockState> {
return data[index(x, y, z)] == null;
}
/**
* Direct slot read, null when unset lets writeBlocks pay one index + one array load per
* block instead of the isAir + getRaw pair.
*/
public PlatformBlockState rawOrNull(int x, int y, int z) {
return data[index(x, y, z)];
}
@Override
public int getWidth() {
return 16;
@@ -226,6 +226,8 @@ public final class ModdedDimensionManager {
ModdedWorldEngines.evictOrThrow(level);
level.save(null, true, false);
serverAccess.removeLevel(server, key);
// Undo snapshots pin the ServerLevel and could replay into the dead level.
art.arcane.iris.modded.command.ModdedObjectUndo.forget(level);
level.close();
HANDLES.remove(dimensionId);
if (wipeStorage) {
@@ -47,6 +47,7 @@ import art.arcane.iris.modded.service.ModdedTreeFellerService;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.util.common.parallel.MultiBurst;
import net.minecraft.core.BlockPos;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
@@ -99,6 +100,11 @@ public final class ModdedEngineBootstrap {
captureInitialSpawn(server);
currentServer = server;
bind();
// Pair of the stop() burst-pools stage. Load-bearing on integrated servers: once
// closed, MultiBurst falls back to a same-thread executor, so a second world load
// without reopen() would silently run every burst inline.
MultiBurst.burst.reopen();
MultiBurst.ioBurst.reopen();
ModdedScheduler scheduler = schedulerOrNull();
if (scheduler != null) {
scheduler.reset();
@@ -170,6 +176,7 @@ public final class ModdedEngineBootstrap {
failure = runStopStage(failure, "wand service", ModdedWandService::clearAll);
failure = runStopStage(failure, "block break handler", ModdedBlockBreakHandler::clear);
failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear);
failure = runStopStage(failure, "gui host", ModdedGuiHost::clear);
failure = runStopStage(failure, "services", () -> services().disableAll());
failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown);
failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear);
@@ -183,6 +190,10 @@ public final class ModdedEngineBootstrap {
failure = runStopStage(failure, "level snapshot", ModdedServerLevels::forget);
}
failure = runStopStage(failure, "generation pool", IrisModdedChunkGenerator::shutdownGenPool);
failure = runStopStage(failure, "burst pools", () -> {
MultiBurst.burst.close();
MultiBurst.ioBurst.close();
});
failure = runStopStage(failure, "sentry", ModdedSentry::flush);
failure = runStopStage(failure, "startup state", ModdedStartup::reset);
failure = runStopStage(failure, "server state", () -> {
@@ -386,7 +397,12 @@ public final class ModdedEngineBootstrap {
ModdedCustomContentRegistry.Discovery customContentDiscovery =
ModdedCustomContentRegistry.discover();
rollback.add(customContentDiscovery::rollback);
ModdedIrisSplash.print(boundLoader);
try {
ModdedIrisSplash.print(boundLoader);
} catch (Throwable splashFailure) {
// A cosmetic banner must never roll back the platform bind.
LOGGER.warn("Iris splash could not be printed", splashFailure);
}
createdServices.enableAll();
runtime = new BoundRuntime(created, createdServices);
rollback.clear();
@@ -18,6 +18,7 @@
package art.arcane.iris.modded;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.RuntimeUiMessages;
@@ -734,7 +735,7 @@ public final class ModdedForcedDatapack {
}
private static Path packsRoot() {
return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs");
return IrisPlatforms.get().packsFolderNoCreate().toPath();
}
private record PublishedState(Path directory, String packsHash) {
@@ -18,6 +18,7 @@
package art.arcane.iris.modded;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.splash.IrisSplashComposer;
import art.arcane.iris.core.splash.IrisSplashRenderer;
@@ -38,7 +39,7 @@ public final class ModdedIrisSplash {
}
private static void printPacks(ModdedLoader loader) {
File packFolder = loader.configDir().resolve("irisworldgen").resolve("packs").toFile();
File packFolder = IrisPlatforms.get().packsFolderNoCreate();
for (String line : IrisSplashComposer.composePackLines(packFolder, IrisLogging::reportError)) {
IrisLogging.info(line);
}
@@ -116,13 +116,68 @@ public final class ModdedPlatform implements IrisPlatform {
return folder;
}
/**
* Modded packs live under config/irisworldgen/packs, not the config/iris data folder. The
* packsFolder overrides are the source of truth; the dataFolder/dataFile overrides below
* re-root any path whose FIRST segment is exactly "packs" so no call site can regress onto
* the empty config/iris/packs directory. Settings, worlds.json, parity/, cache/ and every
* other name stay under config/iris.
*/
@Override
public File packsFolder(String... sub) {
File folder = packsFolderNoCreate(sub);
folder.mkdirs();
return folder;
}
@Override
public File packsFolderNoCreate(String... sub) {
File root = loader.configDir().resolve("irisworldgen").resolve("packs").toFile();
if (sub == null || sub.length == 0) {
return root;
}
return new File(root, String.join(File.separator, sub));
}
@Override
public File dataFolder(String... path) {
if (isPacksPath(path)) {
return packsFolder(stripPacksSegment(path));
}
return IrisPlatform.super.dataFolder(path);
}
@Override
public File dataFolderNoCreate(String... path) {
if (isPacksPath(path)) {
return packsFolderNoCreate(stripPacksSegment(path));
}
return IrisPlatform.super.dataFolderNoCreate(path);
}
@Override
public File dataFile(String... path) {
if (isPacksPath(path)) {
File file = packsFolderNoCreate(stripPacksSegment(path));
file.getParentFile().mkdirs();
return file;
}
File file = new File(dataFolder(), String.join(File.separator, path));
file.getParentFile().mkdirs();
return file;
}
private static boolean isPacksPath(String... path) {
// Exact-segment match only: "packbenchmarks" and "packsx" must stay under config/iris.
return path != null && path.length > 0 && "packs".equals(path[0]);
}
private static String[] stripPacksSegment(String... path) {
String[] sub = new String[path.length - 1];
System.arraycopy(path, 1, sub, 0, sub.length);
return sub;
}
@Override
public File pluginJar() {
File jar = loader.modJar();
@@ -50,6 +50,11 @@ final class ModdedSpawnTableMerger {
}
void initializeVanillaSpawnBiomes(Registry<Biome> registry) {
// Volatile fast path BEFORE the monitor: this runs per mob category per spawn attempt
// on the server thread, and the generator monitor is contended by binds/hotloads.
if (vanillaSpawnBiomesInitialized) {
return;
}
synchronized (generator) {
if (vanillaSpawnBiomesInitialized) {
return;
@@ -59,9 +59,36 @@ public final class ModdedStartup {
if (!PREPARED.compareAndSet(false, true)) {
return;
}
reportLegacyPacksDirectory();
validateAllPacks();
}
/**
* Older builds mkdir'd (and stale guidance sometimes populated) config/iris/packs, but modded
* packs live under config/irisworldgen/packs. Never auto-move user content: warn loudly when
* the legacy directory holds packs, and quietly remove it when it is empty.
*/
private static void reportLegacyPacksDirectory() {
try {
File legacy = ModdedEngineBootstrap.loader().configDir().resolve("iris").resolve("packs").toFile();
if (!legacy.isDirectory()) {
return;
}
if (!PackDirectoryResolver.listVisiblePackDirectories(legacy).isEmpty()) {
File real = art.arcane.iris.spi.IrisPlatforms.get().packsFolderNoCreate();
LOGGER.warn("Iris found packs under the legacy directory {} - modded packs load from {} only. Move them there.",
legacy.getAbsolutePath(), real.getAbsolutePath());
return;
}
String[] entries = legacy.list();
if (entries == null || entries.length == 0) {
legacy.delete();
}
} catch (Throwable e) {
LOGGER.debug("Iris legacy packs directory check failed", e);
}
}
/**
* Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler:
* ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this
@@ -18,6 +18,7 @@
package art.arcane.iris.modded;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.engine.IrisEngine;
@@ -26,6 +27,7 @@ import art.arcane.iris.engine.framework.EngineTarget;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisDimensionRuntimeContract;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.modded.command.ModdedGuiHost;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.storage.LevelResource;
@@ -76,6 +78,8 @@ public final class ModdedWorldEngines {
if (removed[0] == null) {
return;
}
// The GUI host holds strong Engine/ServerLevel references with no other remove path.
ModdedGuiHost.unbind(removed[0]);
LOGGER.info("Iris engine evicted for {}", level.dimension().identifier());
}
@@ -89,6 +93,7 @@ public final class ModdedWorldEngines {
ENGINES.compute(activeLevel, (ServerLevel ignored, Engine current) -> {
if (current != null && current != activeReplacement) {
close(current);
ModdedGuiHost.unbind(current);
}
return activeReplacement;
});
@@ -96,6 +101,7 @@ public final class ModdedWorldEngines {
static void closeUnregistered(Engine engine) {
close(engine);
ModdedGuiHost.unbind(engine);
}
private static Engine create(ServerLevel level, String pack, String dimensionKey, long seedOverride) {
@@ -167,11 +173,7 @@ public final class ModdedWorldEngines {
}
public static File packFolder(String pack) {
return ModdedEngineBootstrap.loader().configDir()
.resolve("irisworldgen")
.resolve("packs")
.resolve(pack)
.toFile();
return IrisPlatforms.get().packsFolderNoCreate(pack);
}
static File resolvePack(String pack, String dimensionKey) {
@@ -200,10 +202,18 @@ public final class ModdedWorldEngines {
ServerLevel level = entry.getKey();
Engine engine = entry.getValue();
try {
close(engine);
if (!ENGINES.remove(level, engine) && ENGINES.containsKey(level)) {
throw new IllegalStateException("Iris engine mapping changed during shutdown for "
+ level.dimension().identifier());
// Latch the generator's unloading flag BEFORE closing (unbindEngine sets it,
// then evicts): chunk-system drain work running after this stage would
// otherwise see a closed engine and silently rebuild a fresh engine + Mantle
// that no teardown stage ever closes, writing plates after the final save.
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator) {
generator.unbindEngine(level);
} else {
close(engine);
if (!ENGINES.remove(level, engine) && ENGINES.containsKey(level)) {
throw new IllegalStateException("Iris engine mapping changed during shutdown for "
+ level.dimension().identifier());
}
}
LOGGER.info("Iris engine closed for {}", level.dimension().identifier());
} catch (Throwable e) {
@@ -565,9 +565,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (worldY <= level.getMinY() || worldY >= level.getMaxY()) {
continue;
}
if (!LootResolver.oneIn(entityRng, entry.getRarity())) {
continue;
}
// Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity).
if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) {
continue;
}
@@ -605,9 +603,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
int worldZ = position.getZ();
int spawned = 0;
for (int i = 0; i < count; i++) {
if (!LootResolver.oneIn(entityRng, entry.getRarity())) {
continue;
}
// Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity).
if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) {
continue;
}
@@ -774,18 +770,8 @@ public final class ModdedWorldManager implements EngineWorldManager {
}
private IrisEntitySpawn rarityPick(KList<IrisEntitySpawn> entries) {
int totalRarity = 0;
for (IrisEntitySpawn entry : entries) {
totalRarity += IRare.get(entry);
}
if (totalRarity <= 0) {
return entries.getRandom();
}
KList<IrisEntitySpawn> weighted = new KList<>();
for (IrisEntitySpawn entry : entries) {
weighted.addMultiple(entry, totalRarity / IRare.get(entry));
}
return weighted.getRandom();
KList<IrisEntitySpawn> weighted = IRare.expandWeighted(entries);
return weighted.isEmpty() ? entries.getRandom() : weighted.getRandom();
}
private static long pack(int x, int z) {
@@ -178,10 +178,16 @@ public final class IrisModdedAPI {
/**
* Declares that mantle slices of {@code sliceType} must be kept rather than discarded.
* <p>
* Iris drops slices it does not need once a region's generation data has served its purpose. Any type a mod
* writes with {@link #setMantleData(ServerLevel, int, int, int, Object)} and expects to read back later must be
* Iris drops slices it does not need once a region's generation data has served its purpose - both the
* normal per-chunk trim and pregeneration's forced cleanup honor this registry. Any type a mod writes
* with {@link #setMantleData(ServerLevel, int, int, int, Object)} and expects to read back later must be
* declared here first. Registration is by canonical class name, process-wide across every Iris world, and
* cannot be undone - declare it once during mod setup. A null {@code sliceType} is ignored.
* cannot be undone - declare it once during mod setup. A null {@code sliceType} is ignored, and the
* block-state slice can never be retained.
* <p>
* Retained data lives for the world's lifetime: it persists into the mantle region files and unloads
* with them, so region files grow with everything you retain. The mod owns the cleanup - call
* {@code deleteMantleData} when a value is no longer needed.
*/
public static void retainMantleDataForSlice(Class<?> sliceType) {
if (sliceType == null) {
@@ -18,6 +18,7 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.IrisStructureLocator;
@@ -230,7 +231,7 @@ final class ModdedCommandSuggestions {
Set<String> names = new TreeSet<>();
names.add("overworld");
try {
File packs = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs").toFile();
File packs = IrisPlatforms.get().packsFolderNoCreate();
for (File child : PackDirectoryResolver.listVisiblePackDirectories(packs)) {
String packName = child.getName();
names.add(packName);
@@ -18,6 +18,7 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.core.pack.PackDownloader;
import com.mojang.brigadier.arguments.BoolArgumentType;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.LongArgumentType;
@@ -190,15 +191,15 @@ final class ModdedCommandTree {
.then(Commands.argument("pack", StringArgumentType.word()).suggests(ModdedCommandSuggestions.PACK_NAMES)
.executes((CommandContext<CommandSourceStack> context) ->
IrisModdedCommands.download(context.getSource(),
StringArgumentType.getString(context, "pack"), "stable", false))
StringArgumentType.getString(context, "pack"), PackDownloader.DEFAULT_BRANCH, false))
.then(Commands.literal("force")
.executes((CommandContext<CommandSourceStack> context) ->
IrisModdedCommands.download(context.getSource(),
StringArgumentType.getString(context, "pack"), "stable", true)))
StringArgumentType.getString(context, "pack"), PackDownloader.DEFAULT_BRANCH, true)))
.then(Commands.argument("overwrite", BoolArgumentType.bool())
.executes((CommandContext<CommandSourceStack> context) ->
IrisModdedCommands.download(context.getSource(),
StringArgumentType.getString(context, "pack"), "stable",
StringArgumentType.getString(context, "pack"), PackDownloader.DEFAULT_BRANCH,
BoolArgumentType.getBool(context, "overwrite"))))
.then(Commands.argument("branch", StringArgumentType.word())
.executes((CommandContext<CommandSourceStack> context) ->
@@ -188,6 +188,9 @@ public final class ModdedDustRevealer {
private static void revealBatch(ModdedScheduler scheduler, RevealRun run,
List<BlockPos> hits, int from) {
if (!active(run)) {
// Drop the registry entry on abort too, or the run record pins the player, level
// and engine until server stop. No-op if a newer run already replaced it.
ACTIVE_RUNS.remove(run.playerId(), run);
return;
}
int to = Math.min(hits.size(), from + PARTICLE_BATCH_SIZE);
@@ -55,6 +55,29 @@ public final class ModdedGuiHost implements GuiHost.Provider {
}
}
/**
* Drops the GUI binding for an evicted engine. Without this the host pinned every
* GUI-bound Engine, its ServerLevel and transitively the MinecraftServer for the process
* lifetime there was no remove path at all.
*/
public static void unbind(Engine engine) {
if (engine == null) {
return;
}
INSTANCE.levels.remove(engine);
INSTANCE.openers.remove(engine);
if (INSTANCE.active == engine) {
INSTANCE.active = null;
}
}
public static void clear() {
INSTANCE.levels.clear();
INSTANCE.openers.clear();
INSTANCE.active = null;
INSTANCE.server = null;
}
public static boolean isGuiLaunchable() {
return GuiHost.isAvailable() && IrisSettings.get().getGui().isUseServerLaunchedGuis();
}
@@ -543,6 +543,16 @@ final class ModdedLocateCommands {
private static void teleportToLocateResult(CommandSourceStack source, ServerLevel level, Engine engine,
ServerPlayer player, String label, Position2 at) {
// Same liveness guards the structure completion path has: the search can take up to
// two minutes, and the captured ServerPlayer may be gone or elsewhere by then.
if (player.hasDisconnected() || player.isRemoved()) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_PLAYER_DISCONNECTED_BEFORE_STRUCTURE_SEARCH_COMPLETED));
return;
}
if (player.level() != level) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_YOU_CHANGED_DIMENSIONS_BEFORE_STRUCTURE_SEARCH_COMPLETED_RUN_COMMAND_AGAIN));
return;
}
int blockX = (at.getX() << 4) + 8;
int blockZ = (at.getZ() << 4) + 8;
try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_locator_teleport");
@@ -291,30 +291,35 @@ public final class ModdedObjectCommands {
}
int[] tilesSkipped = {0};
int[] tilesSaved = {0};
// capture() must stay on the server thread (getBlockState/getBlockEntity are not
// 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);
File parent = file.getParentFile();
if (parent != null) {
parent.mkdirs();
}
try {
object.write(file);
} catch (IOException e) {
LOGGER.error("Iris object save failed for {}", file.getAbsolutePath(), e);
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FAILED_SAVE_OBJECT, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
return 0;
}
StringBuilder tileNote = new StringBuilder();
if (tilesSaved[0] > 0) {
tileNote.append(" (").append(tilesSaved[0]).append(" tile entity state(s) captured");
if (tilesSkipped[0] > 0) {
tileNote.append(", ").append(tilesSkipped[0]).append(" failed");
MinecraftServer server = source.getServer();
J.a(() -> {
File parent = file.getParentFile();
if (parent != null) {
parent.mkdirs();
}
tileNote.append(")");
} else if (tilesSkipped[0] > 0) {
tileNote.append(" (").append(tilesSkipped[0]).append(" tile state(s) could not be captured)");
}
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_SAVED_OBJECTS_IOB_X_X_BLOCK_S, MessageArgument.untrusted("value", engine.getData().getDataFolder().getName()), MessageArgument.untrusted("name", name), MessageArgument.untrusted("w", w), MessageArgument.untrusted("h", h), MessageArgument.untrusted("d", d), MessageArgument.untrusted("value2", object.getBlocks().size()), MessageArgument.untrusted("tileNote", tileNote)));
LOGGER.info("Iris object save: {} {}x{}x{} blocks={} tilesSaved={} tilesSkipped={} -> {}", name, w, h, d, object.getBlocks().size(), tilesSaved[0], tilesSkipped[0], file.getAbsolutePath());
try {
object.write(file);
} catch (IOException e) {
LOGGER.error("Iris object save failed for {}", file.getAbsolutePath(), e);
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FAILED_SAVE_OBJECT, MessageArgument.untrusted("value", String.valueOf(e.getMessage())))));
return;
}
StringBuilder tileNote = new StringBuilder();
if (tilesSaved[0] > 0) {
tileNote.append(" (").append(tilesSaved[0]).append(" tile entity state(s) captured");
if (tilesSkipped[0] > 0) {
tileNote.append(", ").append(tilesSkipped[0]).append(" failed");
}
tileNote.append(")");
} else if (tilesSkipped[0] > 0) {
tileNote.append(" (").append(tilesSkipped[0]).append(" tile state(s) could not be captured)");
}
server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_SAVED_OBJECTS_IOB_X_X_BLOCK_S, MessageArgument.untrusted("value", engine.getData().getDataFolder().getName()), MessageArgument.untrusted("name", name), MessageArgument.untrusted("w", w), MessageArgument.untrusted("h", h), MessageArgument.untrusted("d", d), MessageArgument.untrusted("value2", object.getBlocks().size()), MessageArgument.untrusted("tileNote", tileNote))));
LOGGER.info("Iris object save: {} {}x{}x{} blocks={} tilesSaved={} tilesSkipped={} -> {}", name, w, h, d, object.getBlocks().size(), tilesSaved[0], tilesSkipped[0], file.getAbsolutePath());
});
return 1;
}
@@ -19,6 +19,7 @@
package art.arcane.iris.modded.command;
import net.minecraft.core.BlockPos;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
@@ -88,10 +89,22 @@ public final class ModdedObjectUndo {
if (entry == null) {
break;
}
// Identity check, not just null: a studio closed and reopened under the same
// dimension id must never have blocks replayed into the dead ServerLevel.
MinecraftServer server = entry.level().getServer();
if (server == null || server.getLevel(entry.level().dimension()) != entry.level()) {
LOGGER.warn("Iris object undo: skipped a stale entry for removed dimension {}",
entry.level().dimension().identifier());
continue;
}
int writes = 0;
for (Map.Entry<BlockPos, BlockState> block : entry.blocks().entrySet()) {
entry.level().setBlock(block.getKey(), block.getValue(), Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE);
writes++;
try {
entry.level().setBlock(block.getKey(), block.getValue(), Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE);
writes++;
} catch (Throwable e) {
LOGGER.error("Iris object undo: failed to revert a block at {}", block.getKey(), e);
}
}
LOGGER.info("Iris object undo: reverted {} block(s) in {}", writes, entry.level().dimension().identifier());
reverted++;
@@ -99,6 +112,23 @@ public final class ModdedObjectUndo {
return reverted;
}
/**
* Drops every entry recorded against the given level. Called on dimension removal so a
* closed studio releases its block snapshots and the ServerLevel reference.
*/
public static void forget(ServerLevel level) {
if (level == null) {
return;
}
UNDOS.entrySet().removeIf((Map.Entry<UUID, Deque<Entry>> ownerEntry) -> {
Deque<Entry> queue = ownerEntry.getValue();
synchronized (queue) {
queue.removeIf((Entry entry) -> entry.level() == level);
return queue.isEmpty();
}
});
}
public static void clearAll() {
UNDOS.clear();
}
@@ -18,6 +18,7 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackResourceCleanup;
import art.arcane.iris.core.pack.PackValidationRegistry;
@@ -98,7 +99,7 @@ public final class ModdedPackCommands {
}
public static File packsRoot() {
return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs").toFile();
return IrisPlatforms.get().packsFolderNoCreate();
}
private static int validate(CommandSourceStack source, String pack) {
@@ -23,6 +23,7 @@ import art.arcane.iris.core.gui.GuiHost;
import art.arcane.iris.core.gui.NoiseExplorerGUI;
import art.arcane.iris.core.gui.VisionGUI;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.StructurePackageClosure;
import art.arcane.iris.core.project.IrisProjectCopier;
import art.arcane.iris.engine.framework.Engine;
@@ -31,7 +32,9 @@ import art.arcane.iris.engine.object.IrisBiomeGeneratorLink;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisEntitySpawn;
import art.arcane.iris.engine.object.IrisGenerator;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.core.pack.PackExportClosure;
import art.arcane.iris.engine.object.IrisEntity;
import art.arcane.iris.engine.object.IrisMarker;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisSpawner;
import art.arcane.iris.engine.object.IrisStructurePlacement;
@@ -383,7 +386,7 @@ public final class ModdedStudioCommands {
File packFolder = new File(ModdedPackCommands.packsRoot(), pack);
if (!new File(packFolder, "dimensions/" + pack + ".json").isFile()) {
server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_MISSING_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))));
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true,
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, PackDownloader.DEFAULT_BRANCH, false, true,
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
if (!installed || !new File(packFolder, "dimensions/" + pack + ".json").isFile()) {
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_COULD_NOT_BE_DOWNLOADED_CHECK_NAME_TRY_IRIS_DOWNLOAD, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))));
@@ -467,7 +470,9 @@ public final class ModdedStudioCommands {
ServerPlayer player = source.getPlayer();
MinecraftServer server = source.getServer();
UUID owner = player == null ? CONSOLE_OWNER : player.getUUID();
String dimensionId = STUDIOS.remove(owner);
// Commit the ownership drop only after removal succeeds: dropping it first orphaned a
// still-registered studio that no command could ever remove again.
String dimensionId = STUDIOS.get(owner);
if (dimensionId == null) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN));
return 0;
@@ -479,6 +484,7 @@ public final class ModdedStudioCommands {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSE_FAILED, MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e))));
return 0;
}
STUDIOS.remove(owner, dimensionId);
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSED_WAS_EVACUATED_UNLOADED_ITS_REGION_DATA_DELETED, MessageArgument.untrusted("dimensionId", dimensionId)));
return 1;
}
@@ -588,7 +594,7 @@ public final class ModdedStudioCommands {
File templateFolder = new File(packsRoot, template);
if (!new File(templateFolder, "dimensions/" + template + ".json").isFile()) {
server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template))));
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), template, "master", false, true,
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), template, PackDownloader.DEFAULT_BRANCH, false, true,
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
if (!installed || !new File(templateFolder, "dimensions/" + template + ".json").isFile()) {
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_COULD_NOT_BE_DOWNLOADED_INSTALL_PACK_WITH_DIMENSIONS_JSON, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template))));
@@ -655,6 +661,7 @@ public final class ModdedStudioCommands {
LinkedHashSet<String> generatorKeys = new LinkedHashSet<>();
LinkedHashSet<String> lootKeys = new LinkedHashSet<>();
LinkedHashSet<String> objectKeys = new LinkedHashSet<>();
LinkedHashSet<String> markerKeys = new LinkedHashSet<>();
LinkedHashSet<String> structureKeys = new LinkedHashSet<>();
regionKeys.addAll(dimension.getRegions());
@@ -675,6 +682,8 @@ public final class ModdedStudioCommands {
lootKeys.addAll(region.getLoot().getTables());
spawnerKeys.addAll(region.getEntitySpawners());
collectStructureKeys(structureKeys, region.getStructures());
objectKeys.addAll(PackExportClosure.collectObjectKeys(region.getObjects()));
markerKeys.addAll(PackExportClosure.collectMarkerKeys(region.getObjects()));
}
for (String biomeKey : biomeKeys) {
IrisBiome biome = dm.getBiomeLoader().load(biomeKey);
@@ -685,9 +694,15 @@ public final class ModdedStudioCommands {
lootKeys.addAll(biome.getLoot().getTables());
spawnerKeys.addAll(biome.getEntitySpawners());
collectStructureKeys(structureKeys, biome.getStructures());
for (IrisObjectPlacement placement : biome.getObjects()) {
objectKeys.addAll(placement.getPlace());
objectKeys.addAll(PackExportClosure.collectObjectKeys(biome.getObjects()));
markerKeys.addAll(PackExportClosure.collectMarkerKeys(biome.getObjects()));
}
for (String markerKey : markerKeys) {
IrisMarker marker = dm.getMarkerLoader().load(markerKey);
if (marker == null) {
continue;
}
spawnerKeys.addAll(marker.getSpawners());
}
for (String spawnerKey : spawnerKeys) {
IrisSpawner spawner = dm.getSpawnerLoader().load(spawnerKey);
@@ -695,6 +710,14 @@ public final class ModdedStudioCommands {
continue;
}
spawner.getSpawns().forEach((IrisEntitySpawn spawn) -> entityKeys.add(spawn.getEntity()));
spawner.getInitialSpawns().forEach((IrisEntitySpawn spawn) -> entityKeys.add(spawn.getEntity()));
}
for (String entityKey : entityKeys) {
IrisEntity entity = dm.getEntityLoader().load(entityKey);
if (entity == null) {
continue;
}
lootKeys.addAll(entity.getLoot().getTables());
}
StringBuilder hashes = new StringBuilder();
@@ -732,6 +755,12 @@ public final class ModdedStudioCommands {
for (String key : lootKeys) {
hashes.append(copyJson(folder, "loot", key, dm.getLootLoader().findFile(key)));
}
for (String key : spawnerKeys) {
hashes.append(copyJson(folder, "spawners", key, dm.getSpawnerLoader().findFile(key)));
}
for (String key : markerKeys) {
hashes.append(copyJson(folder, "markers", key, dm.getMarkerLoader().findFile(key)));
}
JSONObject meta = new JSONObject();
meta.put("hash", IO.hash(hashes.toString()));
@@ -794,14 +823,19 @@ public final class ModdedStudioCommands {
int totalTasks = diameter * diameter;
KMap<String, AtomicInteger> counts = new KMap<>();
engine.getDimension().getRegions().forEach((String key) -> counts.put(key, new AtomicInteger(0)));
// finally-scoped: a throw mid-scan previously leaked the sampler's whole
// ForkJoinPool (close() is the only thing that shuts it down).
MultiBurst burst = new MultiBurst("Region Sampler");
BurstExecutor executor = burst.burst(totalTasks);
new Spiraler(diameter, diameter, (int x, int z) -> executor.queue(() -> {
IrisRegion region = engine.getRegion((x << 4) + 8, (z << 4) + 8);
counts.computeIfAbsent(region.getLoadKey(), (String key) -> new AtomicInteger(0)).incrementAndGet();
})).setOffset(blockX >> 4, blockZ >> 4).drain();
executor.complete();
burst.close();
try {
BurstExecutor executor = burst.burst(totalTasks);
new Spiraler(diameter, diameter, (int x, int z) -> executor.queue(() -> {
IrisRegion region = engine.getRegion((x << 4) + 8, (z << 4) + 8);
counts.computeIfAbsent(region.getLoadKey(), (String key) -> new AtomicInteger(0)).incrementAndGet();
})).setOffset(blockX >> 4, blockZ >> 4).drain();
executor.complete();
} finally {
burst.close();
}
server.execute(() -> counts.forEach((String key, AtomicInteger count) -> {
IrisRegion region = engine.getData().getRegionLoader().load(key);
String rarity = region == null ? "?" : String.valueOf(region.getRarity());
@@ -451,6 +451,9 @@ public final class ModdedWhatCommands {
ModdedScheduler scheduler, MarkerRun run,
List<BlockPos> hits, int from) {
if (!active(run)) {
// Drop the registry entry on abort too, or the run record pins the player, level
// and engine until server stop. No-op if a newer run already replaced it.
ACTIVE_MARKER_RUNS.remove(run.playerId(), run);
return;
}
int to = Math.min(hits.size(), from + MARKER_BATCH_SIZE);
@@ -20,6 +20,7 @@ package art.arcane.iris.modded.command;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.modded.IrisModdedChunkGenerator;
@@ -177,7 +178,7 @@ public final class ModdedWorldCommands {
}
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)));
Thread thread = new Thread(() -> {
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true,
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, PackDownloader.DEFAULT_BRANCH, false, true,
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
server.execute(() -> {
if (!installed || !packFolder.isDirectory()) {
@@ -280,7 +281,7 @@ public final class ModdedWorldCommands {
}
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_PACK_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS_2, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack)));
Thread thread = new Thread(() -> {
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true,
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, PackDownloader.DEFAULT_BRANCH, false, true,
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
server.execute(() -> {
if (!installed || !packFolder.isDirectory()) {
@@ -55,6 +55,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
public final class ModdedChunkUpdateService implements ModdedTickableService {
private static final long PASS_PERIOD_MILLIS = 3_000L;
@@ -84,7 +85,17 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
ExecutorService active = warmupExecutor;
warmupExecutor = null;
if (active != null) {
active.shutdown();
// Drop queued warm-ups (pure prefetch) and AWAIT: the very next shutdown stage
// closes every Mantle, and an in-flight mantle.getChunk would fault a plate back
// in after the close-time flush.
active.shutdownNow();
try {
if (!active.awaitTermination(5L, TimeUnit.SECONDS)) {
IrisLogging.warn("Iris mantle warm-up did not stop before engine close");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
warmupQueue.clear();
}
@@ -0,0 +1,31 @@
package art.arcane.iris.modded;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class ModdedDownloadBranchParityTest {
private static final List<String> DOWNLOAD_SOURCES = List.of(
"art/arcane/iris/modded/command/ModdedWorldCommands.java",
"art/arcane/iris/modded/command/ModdedStudioCommands.java",
"art/arcane/iris/modded/command/ModdedCommandTree.java");
@Test
public void implicitAndExplicitDownloadsShareTheCoreDefaultBranch() throws Exception {
for (String source : DOWNLOAD_SOURCES) {
Path path = Path.of(System.getProperty("iris.moddedCommonSources"), source);
String text = Files.readString(path);
assertFalse(source + " must not hardcode a \"master\" download branch",
text.contains("\"master\""));
assertFalse(source + " must not hardcode a \"stable\" download branch",
text.contains("\"stable\""));
assertTrue(source + " must use PackDownloader.DEFAULT_BRANCH",
text.contains("PackDownloader.DEFAULT_BRANCH"));
}
}
}
@@ -36,12 +36,12 @@ public class ModdedLootApplierTest {
}
@Test
public void clearRemovesNativeAndIrisSourcesBeforeAdding() {
public void clearRemovesEverythingAndContributesNothing() {
List<String> sources = new ArrayList<>(List.of("placement-native", "dimension-iris"));
LootResolver.injectSources(sources, List.of("biome-iris"), IrisLootMode.CLEAR, false);
assertEquals(List.of("biome-iris"), sources);
assertEquals(List.of(), sources);
}
@Test
@@ -0,0 +1,106 @@
package art.arcane.iris.modded;
import net.minecraft.core.BlockPos;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.state.BlockState;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.file.Path;
import static org.junit.Assert.assertEquals;
public class ModdedPlatformPathsTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
private ModdedPlatform platform() {
Path configDir = temporaryFolder.getRoot().toPath();
return new ModdedPlatform(new ModdedLoader() {
@Override
public String platformName() {
return "test";
}
@Override
public String minecraftVersion() {
return "26.2";
}
@Override
public String modVersion() {
return "0.0.0";
}
@Override
public MinecraftServer currentServer() {
return null;
}
@Override
public void invalidateLevelCache(MinecraftServer server) {
}
@Override
public boolean clientEnvironment() {
return false;
}
@Override
public Path configDir() {
return configDir;
}
@Override
public File modJar() {
return null;
}
@Override
public boolean hasTreeFellerPermission(ServerPlayer player) {
return false;
}
@Override
public boolean canTreeFellerBreak(ServerLevel level, ServerPlayer player, BlockPos position, BlockState state) {
return false;
}
});
}
@Test
public void packsResolveUnderIrisworldgen() {
ModdedPlatform platform = platform();
File root = temporaryFolder.getRoot();
File packsRoot = new File(new File(root, "irisworldgen"), "packs");
assertEquals(packsRoot, platform.packsFolder());
assertEquals(packsRoot, platform.dataFolder("packs"));
assertEquals(new File(packsRoot, "overworld"), platform.dataFolderNoCreate("packs", "overworld"));
assertEquals(new File(packsRoot, "overworld" + File.separator + "dimensions" + File.separator + "overworld.json"),
platform.dataFile("packs", "overworld", "dimensions", "overworld.json"));
}
@Test
public void everythingElseStaysUnderIris() {
ModdedPlatform platform = platform();
File iris = new File(temporaryFolder.getRoot(), "iris");
assertEquals(iris, platform.dataFolder());
assertEquals(new File(iris, "settings.json"), platform.dataFile("settings.json"));
assertEquals(new File(iris, "parity"), platform.dataFolder("parity"));
}
@Test
public void packsMatchIsExactSegmentOnly() {
ModdedPlatform platform = platform();
File iris = new File(temporaryFolder.getRoot(), "iris");
assertEquals(new File(iris, "packbenchmarks"), platform.dataFolder("packbenchmarks"));
assertEquals(new File(iris, "packsx"), platform.dataFolderNoCreate("packsx"));
}
}
@@ -0,0 +1,75 @@
package art.arcane.iris.modded;
import art.arcane.iris.engine.object.IRare;
import art.arcane.iris.engine.object.IrisEntitySpawn;
import art.arcane.volmlib.util.collection.KList;
import org.junit.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class ModdedSpawnerRarityParityTest {
private static IrisEntitySpawn spawn(int rarity) {
IrisEntitySpawn spawn = new IrisEntitySpawn();
spawn.setRarity(rarity);
return spawn;
}
@Test
public void rarityIsAppliedOnceAsPoolWeightOnly() {
IrisEntitySpawn common = spawn(1);
IrisEntitySpawn rare = spawn(4);
KList<IrisEntitySpawn> expanded = IRare.expandWeighted(List.of(common, rare));
// totalRarity 5 -> common appears 5/1 = 5 times, rare 5/4 = 1 time.
assertEquals(6, expanded.size());
assertEquals(5, expanded.stream().filter(entry -> entry == common).count());
assertEquals(1, expanded.stream().filter(entry -> entry == rare).count());
}
@Test
public void rarityZeroAndNegativeAreClampedToOne() {
assertEquals(1, IRare.get(spawn(0)));
assertEquals(1, IRare.get(spawn(-5)));
assertEquals(3, IRare.get(spawn(3)));
}
@Test
public void perPositionSpawnLoopsDoNotRerollEntryRarity() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.moddedCommonSources"),
"art/arcane/iris/modded/ModdedWorldManager.java"));
for (String method : List.of("private int spawnEntry(", "private int spawnEntryAt(")) {
String body = methodBody(source, method);
assertFalse(method + " must not re-roll rarity per position; rarityPick already weighted the pool",
body.contains("getRarity()"));
assertTrue(method + " must keep the min/max spawn-count roll",
body.contains("LootResolver.inclusive("));
}
}
private static String methodBody(String source, String declaration) {
int start = source.indexOf(declaration);
assertTrue("ModdedWorldManager must declare " + declaration, start >= 0);
int open = source.indexOf('{', start);
int depth = 0;
for (int index = open; index < source.length(); index++) {
char character = source.charAt(index);
if (character == '{') {
depth++;
} else if (character == '}') {
depth--;
if (depth == 0) {
return source.substring(open + 1, index);
}
}
}
throw new AssertionError(declaration + " is not brace balanced");
}
}
@@ -41,7 +41,7 @@ public class IrisSettings {
private IrisSettingsGUI gui = new IrisSettingsGUI();
private IrisSettingsAutoconfiguration autoConfiguration = new IrisSettingsAutoconfiguration();
private IrisSettingsGenerator generator = new IrisSettingsGenerator();
private IrisSettingsConcurrency concurrency = new IrisSettingsConcurrency();
private transient IrisSettingsConcurrency concurrency = new IrisSettingsConcurrency();
private IrisSettingsStudio studio = new IrisSettingsStudio();
private IrisSettingsPerformance performance = new IrisSettingsPerformance();
private IrisSettingsPregen pregen = new IrisSettingsPregen();
@@ -222,7 +222,7 @@ public class IrisWorlds {
dimension = IrisData.loadAnyDimension(id, null);
}
if (dimension == null) {
File packsRoot = IrisPlatforms.get().dataFolderNoCreate(StudioSVC.WORKSPACE_NAME);
File packsRoot = IrisPlatforms.get().packsFolderNoCreate();
if (PackDownloader.isPackPresent(packsRoot, id)) {
IrisLogging.error("Pack '" + id + "' exists at " + new File(packsRoot, id).getPath()
+ " but its dimension failed to load; not redownloading. Fix or delete the pack folder.");
@@ -892,7 +892,7 @@ public class ServerConfigurator {
public static Stream<IrisData> allPacks() {
Stream<File> locals = PackDirectoryResolver.listVisiblePackDirectories(
IrisPlatforms.get().dataFolder("packs")
IrisPlatforms.get().packsFolder()
).stream();
return Stream.concat(locals
.filter(base -> {
@@ -175,7 +175,7 @@ public final class DatapackIngestService {
if (autoIngest && !configured.isEmpty()) {
IrisLogging.info("Validating " + configured.size()
+ " configured external datapack import(s) before player admission...");
Report report = ingest(null, configured, true);
Report report = ingest(null, configured, true, true);
if (!report.getFailed().isEmpty()) {
String failure = report.getFailed().getFirst();
IrisStartupValidation.markDatapacksInvalid(failure);
@@ -513,15 +513,35 @@ public final class DatapackIngestService {
}
public static Report ingest(VolmitSender sender, KList<String> urls, boolean restart) {
IrisStartupValidation.beginDatapackValidation();
return ingest(sender, urls, restart, false);
}
/**
* Only startup validation owns the global player-admission gate (gateAdmission=true). The
* runtime admin command must never flip it: a transient download failure there would lock
* every login until restart, and the PENDING window would close logins for the whole
* (potentially very slow) download. World-creation safety on the ungated path is carried
* by the loaded-runtime invalidation plus requireDatapackRestart when files change.
*/
private static Report ingest(VolmitSender sender, KList<String> urls, boolean restart, boolean gateAdmission) {
if (gateAdmission) {
IrisStartupValidation.beginDatapackValidation();
}
ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation =
ServerConfigurator.invalidateLoadedDatapackRuntime();
Report report;
boolean settled = false;
TRANSACTION_LOCK.lock();
try {
report = ingestLocked(sender, urls, restart);
settled = true;
} finally {
TRANSACTION_LOCK.unlock();
if (!settled && gateAdmission) {
// Never strand the admission gate at PENDING on an unexpected throw.
IrisStartupValidation.markDatapacksInvalid(
"External datapack ingest failed unexpectedly; check the log above.");
}
}
if (!report.changed() && report.getFailed().isEmpty()) {
ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation);
@@ -532,10 +552,12 @@ public final class DatapackIngestService {
ServerConfigurator.requireDatapackRestart();
}
}
if (!report.getFailed().isEmpty()) {
IrisStartupValidation.markDatapacksInvalid(report.getFailed().getFirst());
} else if (!report.changed()) {
IrisStartupValidation.markDatapacksReady();
if (gateAdmission) {
if (!report.getFailed().isEmpty()) {
IrisStartupValidation.markDatapacksInvalid(report.getFailed().getFirst());
} else if (!report.changed()) {
IrisStartupValidation.markDatapacksReady();
}
}
return report;
}
@@ -944,45 +966,61 @@ public final class DatapackIngestService {
}
}
enum RemoveOutcome {
REMOVED,
REJECTED,
FAILED_AFTER_MUTATION
}
public static boolean remove(VolmitSender sender, String id) {
ServerConfigurator.invalidateLoadedDatapackRuntime();
boolean removed;
ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation =
ServerConfigurator.invalidateLoadedDatapackRuntime();
RemoveOutcome outcome;
TRANSACTION_LOCK.lock();
try {
removed = removeLocked(sender, id);
outcome = removeOutcomeLocked(sender, id);
} finally {
TRANSACTION_LOCK.unlock();
}
if (removed) {
if (outcome == RemoveOutcome.REMOVED) {
ServerConfigurator.requireDatapackRestart();
} else if (outcome == RemoveOutcome.REJECTED) {
// Rejected before any mutation: nothing on disk changed, so the loaded runtime is
// still valid. Leaving it invalidated forced a full datapack reinstall on every
// later world creation for the rest of the session.
ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation);
}
return removed;
return outcome == RemoveOutcome.REMOVED;
}
private static boolean removeLocked(VolmitSender sender, String id) {
private static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id) {
File root = IrisPlatforms.get().dataFolder("datapacks");
return removeLocked(sender, id, root, ServerConfigurator.getDatapacksFolder());
return removeOutcomeLocked(sender, id, root, ServerConfigurator.getDatapacksFolder());
}
static boolean removeLocked(VolmitSender sender, String id, File root, List<File> worldFolders) {
return removeOutcomeLocked(sender, id, root, worldFolders) == RemoveOutcome.REMOVED;
}
static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id, File root, List<File> worldFolders) {
String requested = id == null ? "" : id.trim().toLowerCase(Locale.ROOT);
String cleaned = sanitizeId(id);
if (requested.isBlank() || !requested.equals(cleaned) || RESERVED_IDS.contains(cleaned)) {
message(sender, C.RED + "Invalid Iris-managed datapack id '" + requested + "'. Run /iris datapack list and use the exact listed id.");
return false;
return RemoveOutcome.REJECTED;
}
try {
recoverTransactions(root, worldFolders);
} catch (IOException e) {
message(sender, C.RED + "Datapack removal blocked by incomplete transaction recovery: " + e.getMessage());
IrisLogging.reportError(e);
return false;
return RemoveOutcome.REJECTED;
}
Manifest manifest = readManifest(root);
Entry ownedEntry = manifest.findById(cleaned);
if (ownedEntry == null) {
message(sender, C.YELLOW + "No Iris-managed datapack named '" + cleaned + "'. Unmanaged world datapacks are never removed by Iris.");
return false;
return RemoveOutcome.REJECTED;
}
List<File> targets;
@@ -991,7 +1029,7 @@ public final class DatapackIngestService {
} catch (IOException e) {
message(sender, C.RED + "Refused to remove datapack '" + cleaned + "': " + e.getMessage());
IrisLogging.reportError(e);
return false;
return RemoveOutcome.REJECTED;
}
EditableImportRemoval editableRemoval = null;
@@ -1017,7 +1055,7 @@ public final class DatapackIngestService {
removalFailure);
message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN
+ "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone.");
return true;
return RemoveOutcome.REMOVED;
}
boolean restored = rollbackRemoval(manifestWrite, directoryRemoval, editableRemoval, removalFailure);
if (restored && coordinator != null) {
@@ -1030,12 +1068,14 @@ public final class DatapackIngestService {
message(sender, C.RED + "Failed to remove datapack '" + cleaned
+ "'; Iris attempted to restore every prior location: " + removalFailure.getMessage());
IrisLogging.reportError(removalFailure);
return false;
// A mutation was attempted; even a successful rollback is not provably identical
// (the fingerprint does not cover datapacks/), so stay conservatively invalidated.
return RemoveOutcome.FAILED_AFTER_MUTATION;
}
finishCommittedRemoval(cleaned, manifestWrite, directoryRemoval, editableRemoval, coordinator, null);
message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN
+ "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone.");
return true;
return RemoveOutcome.REMOVED;
}
private static void finishCommittedRemoval(
@@ -358,15 +358,18 @@ public final class ModrinthResolver {
connection.setReadTimeout(20000);
connection.setInstanceFollowRedirects(true);
int code = connection.getResponseCode();
if (code != 200) {
connection.disconnect();
throw new IOException("HTTP " + code + " from " + url);
}
// Everything past openConnection under one finally: a timeout/DNS/TLS throw inside
// getResponseCode abandoned the connection undisconnected.
try {
int code = connection.getResponseCode();
if (code != 200) {
throw new IOException("HTTP " + code + " from " + url);
}
try (InputStream input = connection.getInputStream();
InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) {
return readBoundedApiResponse(reader, url, MAX_API_RESPONSE_CHARS);
try (InputStream input = connection.getInputStream();
InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) {
return readBoundedApiResponse(reader, url, MAX_API_RESPONSE_CHARS);
}
} finally {
connection.disconnect();
}
@@ -36,9 +36,17 @@ public class BlockSignal {
public static final AtomicInteger active = new AtomicInteger(0);
public BlockSignal(Block block, int ticks) {
active.incrementAndGet();
// Count only after the entity actually exists, and release unconditionally: a refused
// Folia schedule or a spawn failure must not inflate the throttle counter forever.
Location tg = block.getLocation().clone().add(0.5, 0, 0.5);
FallingBlock e = block.getWorld().spawnFallingBlock(tg, block.getBlockData());
FallingBlock e;
try {
e = block.getWorld().spawnFallingBlock(tg, block.getBlockData());
} catch (Throwable spawnFailure) {
sendBlockRefresh(block);
throw spawnFailure;
}
active.incrementAndGet();
e.setGravity(false);
e.setInvulnerable(true);
e.setGlowing(true);
@@ -55,10 +63,11 @@ public class BlockSignal {
active.decrementAndGet();
sendBlockRefresh(block);
};
if (!J.runAt(blockLocation, removeTask, ticks)) {
if (!J.isFolia()) {
J.s(removeTask, ticks);
}
boolean scheduled = J.runAt(blockLocation, removeTask, ticks);
if (!scheduled && !J.isFolia()) {
J.s(removeTask, ticks);
} else if (!scheduled) {
removeTask.run();
}
}
@@ -36,7 +36,6 @@ import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import lombok.Data;
import org.bukkit.Location;
import org.bukkit.Sound;
import org.bukkit.World;
@@ -47,74 +46,83 @@ import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import java.util.ArrayDeque;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Supplier;
@SuppressWarnings("ALL")
@Data
public class DustRevealer {
private final Engine engine;
private final World world;
private final BlockPosition block;
private final String key;
private final KList<BlockPosition> hits;
// Matches the modded twin (ModdedDustRevealer) so both platforms truncate identically.
private static final int MAX_HITS = 2_048;
private static final int PARTICLE_BATCH_SIZE = 64;
public DustRevealer(Engine engine, World world, BlockPosition block, String key, KList<BlockPosition> hits) {
this.engine = engine;
this.world = world;
this.block = block;
this.key = key;
this.hits = hits;
/**
* Bounded, single-threaded flood fill over the object's placement key, followed by batched
* particle playback on the owning threads. The previous shape recursively spawned one
* scheduler task + one burst task per matched block, all mutating one unsynchronized
* ArrayList and sleep-spinning MultiBurst workers unbounded fan-out on large objects.
*/
private static void reveal(Engine engine, World world, BlockPosition origin, String key) {
KList<BlockPosition> hits = new KList<>();
Set<BlockPosition> visited = new HashSet<>();
ArrayDeque<BlockPosition> frontier = new ArrayDeque<>();
visited.add(origin);
frontier.add(origin);
hits.add(origin);
int minY = world.getMinHeight();
int maxY = world.getMaxHeight();
Location blockLocation = block.toBlock(world).getLocation();
Runnable revealTask = () -> {
BlockSignal.of(world, block.getX(), block.getY(), block.getZ(), 10);
try {
search:
while (!frontier.isEmpty()) {
BlockPosition at = frontier.poll();
for (int dx = -1; dx <= 1; dx++) {
for (int dy = -1; dy <= 1; dy++) {
for (int dz = -1; dz <= 1; dz++) {
if (dx == 0 && dy == 0 && dz == 0) {
continue;
}
BlockPosition next = new BlockPosition(at.getX() + dx, at.getY() + dy, at.getZ() + dz);
if (next.getY() < minY || next.getY() >= maxY || !visited.add(next)) {
continue;
}
if (key.equals(engine.getObjectPlacementKey(next.getX(), next.getY() - minY, next.getZ()))) {
hits.add(next);
frontier.add(next);
if (hits.size() >= MAX_HITS) {
break search;
}
}
}
}
}
}
} catch (Throwable e) {
IrisLogging.reportError(e);
}
revealBatch(world, hits, 0);
}
private static void revealBatch(World world, KList<BlockPosition> hits, int from) {
if (from >= hits.size()) {
return;
}
int to = Math.min(from + PARTICLE_BATCH_SIZE, hits.size());
Runnable batch = () -> {
for (int i = from; i < to; i++) {
BlockPosition p = hits.get(i);
BlockSignal.of(world, p.getX(), p.getY(), p.getZ(), 10);
}
BlockPosition first = hits.get(from);
if (M.r(0.25)) {
world.playSound(block.toBlock(world).getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 1f, RNG.r.f(0.2f, 2f));
world.playSound(first.toBlock(world).getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 1f, RNG.r.f(0.2f, 2f));
}
J.a(() -> {
while (BlockSignal.active.get() > 128) {
J.sleep(5);
}
try {
is(new BlockPosition(block.getX() + 1, block.getY(), block.getZ()));
is(new BlockPosition(block.getX() - 1, block.getY(), block.getZ()));
is(new BlockPosition(block.getX(), block.getY() + 1, block.getZ()));
is(new BlockPosition(block.getX(), block.getY() - 1, block.getZ()));
is(new BlockPosition(block.getX(), block.getY(), block.getZ() + 1));
is(new BlockPosition(block.getX(), block.getY(), block.getZ() - 1));
is(new BlockPosition(block.getX() + 1, block.getY(), block.getZ() + 1));
is(new BlockPosition(block.getX() + 1, block.getY(), block.getZ() - 1));
is(new BlockPosition(block.getX() - 1, block.getY(), block.getZ() + 1));
is(new BlockPosition(block.getX() - 1, block.getY(), block.getZ() - 1));
is(new BlockPosition(block.getX() + 1, block.getY() + 1, block.getZ()));
is(new BlockPosition(block.getX() + 1, block.getY() - 1, block.getZ()));
is(new BlockPosition(block.getX() - 1, block.getY() + 1, block.getZ()));
is(new BlockPosition(block.getX() - 1, block.getY() - 1, block.getZ()));
is(new BlockPosition(block.getX(), block.getY() + 1, block.getZ() - 1));
is(new BlockPosition(block.getX(), block.getY() + 1, block.getZ() + 1));
is(new BlockPosition(block.getX(), block.getY() - 1, block.getZ() - 1));
is(new BlockPosition(block.getX(), block.getY() - 1, block.getZ() + 1));
is(new BlockPosition(block.getX() - 1, block.getY() + 1, block.getZ() - 1));
is(new BlockPosition(block.getX() - 1, block.getY() + 1, block.getZ() + 1));
is(new BlockPosition(block.getX() - 1, block.getY() - 1, block.getZ() - 1));
is(new BlockPosition(block.getX() - 1, block.getY() - 1, block.getZ() + 1));
is(new BlockPosition(block.getX() + 1, block.getY() + 1, block.getZ() - 1));
is(new BlockPosition(block.getX() + 1, block.getY() + 1, block.getZ() + 1));
is(new BlockPosition(block.getX() + 1, block.getY() - 1, block.getZ() - 1));
is(new BlockPosition(block.getX() + 1, block.getY() - 1, block.getZ() + 1));
} catch (Throwable e) {
IrisLogging.reportError(e);
e.printStackTrace();
}
});
revealBatch(world, hits, to);
};
int delay = RNG.r.i(2, 8);
if (!J.runAt(blockLocation, revealTask, delay)) {
if (!J.isFolia()) {
J.s(revealTask, delay);
}
Location at = hits.get(from).toBlock(world).getLocation();
if (!J.runAt(at, batch, 1) && !J.isFolia()) {
J.s(batch, 1);
}
}
@@ -137,9 +145,7 @@ public class DustRevealer {
RuntimeUiMessages.DUST_FOUND_OBJECT,
MessageArgument.untrusted("object", a)
));
J.a(() -> {
new DustRevealer(access, world, new BlockPosition(block.getX(), block.getY(), block.getZ()), a, new KList<>());
});
J.a(() -> reveal(access, world, new BlockPosition(block.getX(), block.getY(), block.getZ()), a));
}
}
}
@@ -354,24 +360,6 @@ public class DustRevealer {
}
}
private boolean is(BlockPosition a) {
if (a.getY() < world.getMinHeight() || a.getY() >= world.getMaxHeight()) {
return false;
}
int betterY = a.getY() - world.getMinHeight();
if (isValidTry(a) && engine.getObjectPlacementKey(a.getX(), betterY, a.getZ()) != null && engine.getObjectPlacementKey(a.getX(), betterY, a.getZ()).equals(key)) {
hits.add(a);
new DustRevealer(engine, world, a, key, hits);
return true;
}
return false;
}
private boolean isValidTry(BlockPosition b) {
return !hits.contains(b);
}
private record DustLine(String text, boolean emphasis) {
}
}
@@ -42,7 +42,10 @@ import java.util.concurrent.locks.ReentrantLock;
public final class PregenRenderer extends JPanel implements KeyListener {
private static final long serialVersionUID = 2094606939770332040L;
private final KList<Runnable> order = new KList<>();
// Backstop: if paint() ever stalls (iconified frame, EDT hiccup), the producer must not
// grow this without bound one entry per drawn chunk adds up fast on a big pregen.
private static final int MAX_QUEUED_DRAWS = 250_000;
private KList<Runnable> order = new KList<>();
private final ReentrantLock lock = new ReentrantLock();
private final int res = 512;
private final BufferedImage image = new BufferedImage(res, res, BufferedImage.TYPE_INT_RGB);
@@ -71,7 +74,9 @@ public final class PregenRenderer extends JPanel implements KeyListener {
return (Position2 c, Color color) -> {
lock.lock();
try {
order.add(() -> draw(c, color, bg));
if (order.size() < MAX_QUEUED_DRAWS) {
order.add(() -> draw(c, color, bg));
}
} finally {
lock.unlock();
}
@@ -83,7 +88,11 @@ public final class PregenRenderer extends JPanel implements KeyListener {
}
public boolean isVisibleFrame() {
return frame != null && frame.isVisible();
// An iconified frame still reports isVisible() but AWT stops repainting it, which
// would stop the only queue drain while the producer keeps appending.
JFrame activeFrame = frame;
return activeFrame != null && activeFrame.isVisible()
&& (activeFrame.getExtendedState() & java.awt.Frame.ICONIFIED) == 0;
}
public void close() {
@@ -99,18 +108,23 @@ public final class PregenRenderer extends JPanel implements KeyListener {
public void paint(Graphics gx) {
Graphics2D g = (Graphics2D) gx;
bg = (Graphics2D) image.getGraphics();
// Swap the queue under the lock and drain outside it: generation threads must not
// block on the lock while the EDT walks a large batch, and pop()-per-entry was O(N^2).
KList<Runnable> batch;
lock.lock();
try {
while (order.isNotEmpty()) {
try {
order.pop().run();
} catch (Throwable e) {
IrisLogging.reportError(e);
}
}
batch = order;
order = new KList<>();
} finally {
lock.unlock();
}
for (Runnable r : batch) {
try {
r.run();
} catch (Throwable e) {
IrisLogging.reportError(e);
}
}
g.drawImage(image, 0, 0, getParent().getWidth(), getParent().getHeight(), (img, infoflags, x, y, width, height) -> true);
g.setColor(Color.WHITE);
@@ -38,7 +38,6 @@ import art.arcane.volmlib.util.function.Consumer2;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.math.Position2;
import art.arcane.volmlib.util.scheduling.ChronoLatch;
import art.arcane.iris.util.common.scheduling.J;
import java.awt.Color;
@@ -46,12 +45,14 @@ import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
public class PregeneratorJob implements PregenListener, PregenRenderSource {
private static final long WORLD_SHUTDOWN_TIMEOUT_MILLIS = 15_000L;
// Must exceed the worker's own worst-case teardown budget (AsyncPregenMethod close:
// 60s permit drain + 120s flush + plate reclaim), or a routine drain trips the deadline
// and aborts the engine shutdown sequence mid-teardown.
private static final long WORLD_SHUTDOWN_TIMEOUT_MILLIS = 200_000L;
private static final Color COLOR_EXISTS = parseColor("#4d7d5b");
private static final Color COLOR_BLACK = parseColor("#4d7d5b");
private static final Color COLOR_MANTLE = parseColor("#3c2773");
@@ -69,14 +70,12 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
private final IrisPregenerator pregenerator;
private final Position2 min;
private final Position2 max;
private final ChronoLatch cl = new ChronoLatch(TimeUnit.MINUTES.toMillis(1));
private final Engine engine;
private final ExecutorService service;
private final Thread worker;
private final PregenPhaseTracker apiPhases = new PregenPhaseTracker();
private PregenRenderer renderer;
private Consumer2<Position2, Color> drawFunction;
private int rgc = 0;
private String[] info;
private volatile double lastChunksPerSecond = 0D;
private volatile long lastChunksRemaining = 0L;
@@ -87,14 +86,6 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
private volatile String lastMethod = IrisLanguage.plain(DesktopUiMessages.PREGEN_METHOD_PENDING);
public PregeneratorJob(PregenTask task, PregeneratorMethod method, Engine engine) {
instance.updateAndGet(old -> {
if (old != null) {
old.pregenerator.close();
old.worker.interrupt();
old.close();
}
return this;
});
this.engine = engine;
monitor = new MemoryMonitor(50);
saving = false;
@@ -120,6 +111,19 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
worker.setPriority(Thread.MIN_PRIORITY);
worker.setDaemon(true);
worker.setUncaughtExceptionHandler((thread, ex) -> IrisLogging.reportError(ex));
// Publish into the static only after every field is assigned (the volatile swap is
// what makes them visible to metrics/shutdown readers), and start the worker after
// publication so it also sees a complete object.
// CAS-or-throw: updateAndGet must be side-effect free (it can re-apply on
// contention), and silently killing the previous job overlapped its 60s+120s
// teardown with the new job's generation. Replacement goes through
// shutdownAndWait first, matching the modded adapter's rejection contract.
if (!instance.compareAndSet(null, this)) {
monitor.close();
service.shutdown();
throw new IllegalStateException("An Iris pregeneration job is already running; stop it first.");
}
worker.start();
}
@@ -138,6 +142,14 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
return false;
}
if (!inst.worker.isAlive() && inst.worker.getState() != Thread.State.NEW) {
// The worker died without running onClose (early abort); clear the phantom job so
// it stops suppressing entity spawns and blocking future pregens. A NEW worker is
// a job mid-construction, not a dead one.
instance.compareAndSet(inst, null);
return false;
}
J.a(() -> {
inst.pregenerator.close();
inst.worker.interrupt();
@@ -432,17 +444,11 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
@Override
public void onRegionGenerated(int x, int z) {
shouldGc();
rgc++;
// No forced System.gc() here: a wall-clock full STW collection mid-generation stalled
// everything; MantleHeapPressure's 96% panic reclaim already owns heap pressure.
broadcastRegionDelta(x, z, IrisMessage.PregenRegionDelta.STATE_DONE);
}
private void shouldGc() {
if (cl.flip() && rgc > 16) {
System.gc();
}
}
private void broadcastRegionDelta(int regionX, int regionZ, int state) {
IrisProtocolServer protocolServer = IrisServices.getOrNull(IrisProtocolServer.class);
if (protocolServer == null) {
@@ -24,6 +24,7 @@ import art.arcane.iris.engine.framework.render.IrisRenderer;
import art.arcane.iris.engine.framework.render.RenderType;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.PreservationRegistry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisRegion;
@@ -281,7 +282,7 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
}
public boolean updateEngine() {
if (engine.isClosed()) {
if (engine.isClosed() || engine.isClosing() || engine.getComplex() == null) {
try {
Engine reacquired = GuiHost.get().findActiveEngine();
if (reacquired != null && !reacquired.isClosed()) {
@@ -451,12 +452,19 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
double mk = mscale;
double mkd = scale;
e.submit(() -> {
PrecisionStopwatch ps = PrecisionStopwatch.start();
BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / (lowtile ? 3 : 1), currentType);
rs.put(ps.getMilliseconds());
working.remove(key);
if (mk == mscale && mkd == scale) {
positions.put(key, b);
// finally: a render throw is swallowed by the discarded Future, and a
// leaked key permanently blocks the admission gate (working < 9).
try {
PrecisionStopwatch ps = PrecisionStopwatch.start();
BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / (lowtile ? 3 : 1), currentType);
rs.put(ps.getMilliseconds());
if (mk == mscale && mkd == scale) {
positions.put(key, b);
}
} catch (Throwable ex) {
IrisLogging.debug("Vision tile render failed: " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
} finally {
working.remove(key);
}
});
}
@@ -472,12 +480,17 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
double mk = mscale;
double mkd = scale;
eh.submit(() -> {
PrecisionStopwatch ps = PrecisionStopwatch.start();
BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / lowq, currentType);
rs.put(ps.getMilliseconds());
workingfast.remove(key);
if (mk == mscale && mkd == scale) {
fastpositions.put(key, b);
try {
PrecisionStopwatch ps = PrecisionStopwatch.start();
BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / lowq, currentType);
rs.put(ps.getMilliseconds());
if (mk == mscale && mkd == scale) {
fastpositions.put(key, b);
}
} catch (Throwable ex) {
IrisLogging.debug("Vision tile render failed: " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
} finally {
workingfast.remove(key);
}
});
return null;
@@ -507,12 +520,30 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
@Override
public void paint(Graphics gx) {
if (engine.isClosed() && !updateEngine()) {
// Cover the closing window too: releaseRuntime nulls the complex BEFORE closed flips,
// and a paint NPE in that window killed the self-driven repaint loop forever.
if ((engine.isClosed() || engine.isClosing() || engine.getComplex() == null) && !updateEngine()) {
EventQueue.invokeLater(() -> {
try { setVisible(false); } catch (Throwable ignored) { }
});
return;
}
try {
paintBody(gx);
} catch (Throwable e) {
IrisLogging.debug("Vision paint failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
} finally {
// The repaint loop is entirely self-driven from here; it must survive any render
// exception or the window freezes on a stale frame permanently.
long sleepMs = eco ? 32 : 16;
J.a(() -> {
J.sleep(sleepMs);
repaint();
});
}
}
private void paintBody(Graphics gx) {
velocity = Math.abs(ox - oxp) * 0.36 + Math.abs(oz - ozp) * 0.36;
oxp = lerp(oxp, ox, 0.36);
@@ -587,6 +618,16 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
}
}
// Bounded, not pruned-to-frame: the low-quality cache is the pan placeholder, but
// unbounded growth while panning leaked one BufferedImage per visited tile forever.
if (fastpositions.size() > 4096) {
for (BlockPosition i : fastpositions.k()) {
if (!gg.contains(i)) {
fastpositions.remove(i);
}
}
}
handleFollow();
renderOverlays(g, p.getMilliseconds());
@@ -594,12 +635,6 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
return;
}
long targetMs = eco ? 32 : 16;
long sleepMs = Math.max(1, targetMs - (long) p.getMilliseconds());
J.a(() -> {
J.sleep(sleepMs);
repaint();
});
}
private void renderGrid(Graphics2D g, int tileSize, double offsetX, double offsetZ) {
@@ -768,7 +768,7 @@ public final class IrisWorldRemovalService {
public CompletableFuture<Void> endMaintenance(ResolvedWorld resolvedWorld) {
World world = resolvedWorld.loadedWorld();
if (world != null) {
IrisToolbelt.endWorldMaintenance(world, "world-remove");
IrisToolbelt.endWorldMaintenance(world, "world-remove", true);
}
return CompletableFuture.completedFuture(null);
}
@@ -3,14 +3,17 @@ package art.arcane.iris.core.link;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.engine.data.cache.AtomicCache;
import art.arcane.volmlib.util.data.Cuboid;
import art.arcane.volmlib.util.scheduling.ChronoLatch;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.entity.Player;
import java.lang.reflect.InvocationTargetException;
import java.util.function.BooleanSupplier;
public class WorldEditLink {
private static final AtomicCache<Boolean> active = new AtomicCache<>();
private static final ChronoLatch errorThrottle = new ChronoLatch(60_000);
public static Cuboid getSelection(Player p) {
if (!hasWorldEdit())
@@ -42,16 +45,40 @@ public class WorldEditLink {
(int) min.getClass().getDeclaredMethod("z").invoke(max)
);
} catch (Throwable e) {
IrisLogging.error("Could not get selection");
e.printStackTrace();
IrisLogging.reportError(e);
active.reset();
active.aquire(() -> false);
if (errorThrottle.flip()) {
IrisLogging.error("Could not get selection");
e.printStackTrace();
IrisLogging.reportError(e);
}
invalidate();
}
return null;
}
public static boolean hasWorldEdit() {
return active.aquire(() -> Bukkit.getPluginManager().isPluginEnabled("WorldEdit"));
return hasWorldEdit(() -> Bukkit.getPluginManager().isPluginEnabled("WorldEdit"));
}
static boolean hasWorldEdit(BooleanSupplier detector) {
if (Boolean.TRUE.equals(active.getIfPresent())) {
return true;
}
boolean present;
try {
present = detector.getAsBoolean();
} catch (Throwable ignored) {
return false;
}
if (present) {
active.aquire(() -> Boolean.TRUE);
}
return present;
}
static void invalidate() {
active.reset();
}
}
@@ -43,6 +43,7 @@ import art.arcane.iris.engine.object.IrisBlockData;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisEntity;
import art.arcane.iris.engine.object.IrisExpression;
import art.arcane.iris.engine.object.IrisObjectScale;
import art.arcane.iris.engine.object.IrisGenerator;
import art.arcane.iris.engine.object.IrisImage;
import art.arcane.iris.engine.object.IrisJigsawPiece;
@@ -86,12 +87,20 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Data
public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
private static final Map<File, IrisData> dataLoaders = new ConcurrentHashMap<>();
// Loaders (cached or detached) that currently have registered engines; see hasActiveEngines.
// Identity-keyed on purpose: Lombok's @Data hashCode over this class's mutable loader state
// drifts while an engine runs, so a hashing set would silently fail removal and pin every
// engine-hosting IrisData (and its pack graph) for the JVM lifetime.
private static final Set<IrisData> ENGINE_HOLDERS =
Collections.synchronizedSet(Collections.newSetFromMap(new IdentityHashMap<>()));
private final File dataFolder;
private final int id;
private final boolean datapackCompiler;
@@ -262,7 +271,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
}
for (File i : PackDirectoryResolver.listVisiblePackDirectories(
IrisPlatforms.get().dataFolder("packs"))) {
IrisPlatforms.get().packsFolder())) {
IrisData dm = get(i);
if (dm == nearest) continue;
T t = dm.load(type, key, false);
@@ -338,6 +347,30 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
}
}
/**
* True when any loader including detached {@link #openRuntime(File)} instances, which
* never enter the dataLoaders cache has a live engine reading the given pack folder.
* The dataLoaders cache cannot answer this question: engines only ever attach to
* openRuntime instances.
*/
public static boolean hasActiveEngines(File dataFolder) {
Path target = dataFolder.toPath().toAbsolutePath().normalize();
IrisData[] holders;
synchronized (ENGINE_HOLDERS) {
holders = ENGINE_HOLDERS.toArray(new IrisData[0]);
}
for (IrisData data : holders) {
if (data.getEngines().isEmpty()) {
ENGINE_HOLDERS.remove(data);
continue;
}
if (data.getDataFolder().toPath().toAbsolutePath().normalize().equals(target)) {
return true;
}
}
return false;
}
public void registerEngine(Engine engine) {
Objects.requireNonNull(engine, "engine");
synchronized (engines) {
@@ -348,6 +381,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
}
engines.add(engine);
}
ENGINE_HOLDERS.add(this);
}
public void unregisterEngine(Engine engine) {
@@ -359,9 +393,12 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
while (iterator.hasNext()) {
if (iterator.next() == engine) {
iterator.remove();
return;
break;
}
}
if (engines.isEmpty()) {
ENGINE_HOLDERS.remove(this);
}
}
}
@@ -383,6 +420,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
synchronized (engines) {
engines.clear();
}
ENGINE_HOLDERS.remove(this);
dataLoaders.remove(dataFolder, this);
}
@@ -425,6 +463,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
public synchronized void hotloaded() {
StructureGraphCatalog.invalidate(this);
IrisObjectScale.invalidate();
closed = false;
possibleSnippets = new KMap<>();
builder = new GsonBuilder()
@@ -490,6 +529,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
public void dump() {
StructureGraphCatalog.invalidate(this);
IrisObjectScale.invalidate();
for (ResourceLoader<?> i : loaders.values()) {
i.clearCache();
}
@@ -63,8 +63,8 @@ import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Predicate;
@@ -79,14 +79,8 @@ import java.util.zip.GZIPOutputStream;
public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
public static final AtomicDouble tlt = new AtomicDouble(0);
private static final int CACHE_SIZE = 100000;
private static final ExecutorService schemaBuildExecutor = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Iris-Schema-Builder");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
});
private static volatile ExecutorService schemaBuildExecutor;
private static final Set<String> schemaBuildQueue = ConcurrentHashMap.newKeySet();
private static final AtomicBoolean schemaBuildExecutorRegistered = new AtomicBoolean();
protected final AtomicCache<KList<File>> folderCache;
protected volatile KSet<String> firstAccess;
protected File root;
@@ -124,11 +118,32 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
IrisLogging.debug("Loader<" + C.GREEN + resourceTypeName + C.LIGHT_PURPLE + "> created in " + C.RED + "IDM/" + manager.getId() + C.LIGHT_PURPLE + " on " + C.GRAY + manager.getDataFolder().getPath());
if (options.registerPreservation()) {
IrisServices.get(PreservationRegistry.class).registerCache(this);
}
}
/**
* Lazily (re)creates the schema-build pool. PreservationSVC shutdownNow()s every
* registered executor on plugin disable; a static final pool registered once was dead for
* the rest of the JVM after a hot reload, breaking every studio workspace refresh. Each
* created pool is registered with the CURRENT cycle's preservation registry.
*/
private static synchronized ExecutorService schemaBuildExecutor() {
ExecutorService current = schemaBuildExecutor;
if (current == null || current.isShutdown()) {
schemaBuildQueue.clear();
current = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Iris-Schema-Builder");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
});
schemaBuildExecutor = current;
PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class);
if (preservation != null && schemaBuildExecutorRegistered.compareAndSet(false, true)) {
preservation.register(schemaBuildExecutor);
if (preservation != null) {
preservation.register(current);
}
}
return current;
}
public JSONObject buildSchema() {
@@ -145,15 +160,22 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
File a = new File(getManager().getDataFolder(), ".iris/schema/" + getFolderName() + "-schema.json");
String schemaPath = a.getAbsolutePath();
if (schemaBuildQueue.add(schemaPath)) {
schemaBuildExecutor.execute(() -> {
try {
IO.writeAll(a, new SchemaBuilder(objectClass, manager).construct().toString(4));
} catch (Throwable e) {
IrisLogging.reportError(e);
} finally {
schemaBuildQueue.remove(schemaPath);
}
});
try {
schemaBuildExecutor().execute(() -> {
try {
IO.writeAll(a, new SchemaBuilder(objectClass, manager).construct().toString(4));
} catch (Throwable e) {
IrisLogging.reportError(e);
} finally {
schemaBuildQueue.remove(schemaPath);
}
});
} catch (RejectedExecutionException rejected) {
// Never let a dead pool leak the queue entry or escape into workspace
// generation (which would delete the user's .code-workspace on the way out).
schemaBuildQueue.remove(schemaPath);
IrisLogging.warn("Schema build skipped (executor unavailable): " + schemaPath);
}
}
return o;
@@ -475,7 +497,9 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
}
KSet<String> set = firstAccess;
if (set != null) set.add(name);
// contains-first: CHM.add takes the bin monitor even when the key is present, and
// every generation thread hammers the same few keys through this line.
if (set != null && !set.contains(name)) set.add(name);
return loadCache.get(name);
}
@@ -148,7 +148,7 @@ public final class DirectorCommandMessages {
);
public static final TextKey COMMAND_DEVELOPER_DIRECTOR_GENERATE_CHUNKS_INTO_BUFFERS_NO_WORLD_WRITES_HASH_BLOCKS_BIOMES_CAPTURES_GOLDEN = TextKey.of(
"iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden",
"Generate chunks into buffers (no world writes) and hash blocks+biomes; captures a golden file or verifies against an existing one. Resets mantle in the scanned area - use on disposable test worlds."
"Generate chunks into buffers (no world writes) and hash blocks+biomes; captures a golden file or verifies against an existing one. Deletes the world's entire mantle - use on disposable test worlds."
);
public static final TextKey COMMAND_DEVELOPER_PARAM_WORLD_SCAN = TextKey.of(
"iris.director.commanddeveloper.param.world_scan",
@@ -168,7 +168,7 @@ public final class DirectorCommandMessages {
);
public static final TextKey COMMAND_DEVELOPER_PARAM_DELETE_MANTLE_DATA_SCAN_AREA_FIRST_FULL_REGENERATION_FROM_SCRATCH = TextKey.of(
"iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch",
"Delete mantle data in the scan area first for full regeneration from scratch"
"Delete the world's entire mantle folder first for full regeneration from scratch"
);
public static final TextKey COMMAND_DEVELOPER_PARAM_CONCURRENT_CHUNK_GENERATIONS_1_STRICTLY_SERIAL_ORDER_DEPENDENCE_TESTING = TextKey.of(
"iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing",
@@ -25,9 +25,37 @@ import org.bukkit.Bukkit;
public class INMS {
//@done
private static final INMSBinding binding = bind();
private static final INMSBinding binding;
private static final Throwable bindFailure;
// A throwing field initializer would leave this class permanently erroneous every
// later touch raises NoClassDefFoundError and buries the real "unsupported Minecraft
// version" message. Capture the failure so callers can report the actual cause.
static {
INMSBinding bound = null;
Throwable failure = null;
try {
bound = bind();
} catch (Throwable t) {
failure = t;
}
binding = bound;
bindFailure = failure;
}
public static boolean isBound() {
return binding != null;
}
public static Throwable bindFailure() {
return bindFailure;
}
public static INMSBinding get() {
if (binding == null) {
throw new IllegalStateException("Iris NMS binding is unavailable: "
+ (bindFailure == null ? "unknown failure" : bindFailure.getMessage()), bindFailure);
}
return binding;
}
@@ -262,6 +262,13 @@ public interface INMSBinding {
return true;
}
/**
* Removes any instrumentation installed by {@link #injectBukkit()}. Must be idempotent;
* called on plugin disable and pre-unload so the transformer cannot outlive the plugin.
*/
default void uninjectBukkit() {
}
KMap<Material, List<BlockProperty>> getBlockProperties();
private void validateDimensionTypes(WorldCreator c) {
@@ -154,7 +154,7 @@ public class IrisPack {
* @return the file path
*/
public static File packsPack(String name) {
return IrisPlatforms.get().dataFolderNoCreate(StudioSVC.WORKSPACE_NAME, name);
return IrisPlatforms.get().packsFolderNoCreate(name);
}
private static KList<File> collectFiles(File f, String fileExtension) {
@@ -47,7 +47,7 @@ public class IrisPackRepository {
private String repo = "overworld";
@Builder.Default
private String branch = "stable";
private String branch = PackDownloader.DEFAULT_BRANCH;
@Builder.Default
private String tag = "";
@@ -96,7 +96,7 @@ public class IrisPackRepository {
return IrisPackRepository.builder()
.user("IrisDimensions")
.repo(g)
.branch("stable")
.branch(PackDownloader.DEFAULT_BRANCH)
.build();
}
@@ -112,7 +112,7 @@ public class IrisPackRepository {
}
public void install(VolmitSender sender, Runnable whenComplete) throws MalformedURLException {
File pack = IrisPlatforms.get().dataFolderNoCreate(StudioSVC.WORKSPACE_NAME, getRepo());
File pack = IrisPlatforms.get().packsFolderNoCreate(getRepo());
if (!pack.exists()) {
File dl = new File(IrisPlatforms.get().dataFolder("cache", "temp"), "dltk-" + UUID.randomUUID() + ".zip");
@@ -0,0 +1,87 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pack;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* Validates biome layer stacks. caveCeilingLayers reuses the height generators built from layers,
* so a biome with more ceiling entries than surface entries has no generator for the extras; the
* engine skips them, and this validator surfaces the mistake to the author at validate time.
*/
final class PackBiomeLayerValidator {
/** Both layers and caveCeilingLayers default to a single entry when absent (IrisBiome field initializers). */
private static final int DEFAULT_LAYER_COUNT = 1;
private PackBiomeLayerValidator() {
}
static List<String> validateCeilingLayerCounts(File biomesFolder) {
List<String> blockingErrors = new ArrayList<>();
if (biomesFolder == null || !biomesFolder.isDirectory()) {
return blockingErrors;
}
List<File> biomeFiles = PackValidationIo.listJsonRecursive(biomesFolder);
biomeFiles.sort(Comparator.comparing(File::getPath));
for (File biomeFile : biomeFiles) {
String biomeKey = PackValidationIo.deriveKey(biomesFolder, biomeFile);
JSONObject biome;
try {
biome = new JSONObject(Files.readString(biomeFile.toPath(), StandardCharsets.UTF_8));
} catch (Throwable e) {
// Invalid JSON is reported by the graph validators; layer counts have nothing to add.
continue;
}
Integer layers = arrayLength(biome, "layers", biomeKey, blockingErrors);
Integer ceiling = arrayLength(biome, "caveCeilingLayers", biomeKey, blockingErrors);
if (layers == null || ceiling == null) {
continue;
}
if (ceiling > layers) {
blockingErrors.add("Biome '" + biomeKey + "' declares " + ceiling + " caveCeilingLayers but only "
+ layers + " layers. caveCeilingLayers reuses the layers height generators and must not have more entries.");
}
}
return blockingErrors;
}
private static Integer arrayLength(JSONObject biome, String field, String biomeKey, List<String> blockingErrors) {
if (!biome.has(field) || biome.isNull(field)) {
return DEFAULT_LAYER_COUNT;
}
JSONArray array = biome.optJSONArray(field);
if (array == null) {
blockingErrors.add("Biome '" + biomeKey + "' " + field + " must be an array.");
return null;
}
return array.length();
}
}
@@ -18,6 +18,7 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.engine.object.IrisDimensionType;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
@@ -45,6 +46,7 @@ final class PackDimensionValidator {
}
validateImportedStructurePolicy(dimensionKey, dimJson, blockingErrors, warnings);
validateDimensionHeights(packFolder, dimensionKey, dimJson, blockingErrors);
JSONArray regionsArray = dimJson.optJSONArray("regions");
if (regionsArray == null || regionsArray.length() == 0) {
@@ -226,4 +228,84 @@ final class PackDimensionValidator {
}
return resolved;
}
/**
* Mirrors the IrisDimensionType constructor checks so a pack that would throw at world creation
* fails validation instead. Defaults must match the POJOs exactly: dimensionHeight absent means
* the IrisDimension field initializer (-64..320); present-but-partial means the IrisRange field
* initializers (min 16, max 32); logicalHeight absent means 256.
*/
static void validateDimensionHeights(File packFolder, String dimensionKey, JSONObject dimJson, List<String> blockingErrors) {
JSONObject range = resolveDimensionHeight(packFolder, dimJson);
if (range == null && dimJson.has("dimensionHeight") && !dimJson.isNull("dimensionHeight")) {
if (!(dimJson.opt("dimensionHeight") instanceof String)) {
blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight must be an object or a range snippet reference.");
}
// Unresolvable snippet references are reported by the content-key machinery.
return;
}
int minY;
int maxY;
if (range == null) {
minY = -64;
maxY = 320;
} else {
minY = (int) range.optDouble("min", 16D);
maxY = (int) range.optDouble("max", 32D);
}
int height = maxY - minY;
int logicalHeight = dimJson.optInt("logicalHeight", 256);
if (height < IrisDimensionType.MIN_HEIGHT || height > IrisDimensionType.MAX_HEIGHT) {
blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight span (max - min) is " + height
+ "; it must be between " + IrisDimensionType.MIN_HEIGHT + " and " + IrisDimensionType.MAX_HEIGHT + ".");
} else if ((height & (IrisDimensionType.HEIGHT_STEP - 1)) != 0) {
blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight span (max - min) is " + height
+ "; it must be a multiple of " + IrisDimensionType.HEIGHT_STEP + ".");
}
if (minY < IrisDimensionType.MIN_MIN_Y || minY > IrisDimensionType.MAX_MIN_Y) {
blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight.min is " + minY
+ "; it must be between " + IrisDimensionType.MIN_MIN_Y + " and " + IrisDimensionType.MAX_MIN_Y + ".");
} else if ((minY & (IrisDimensionType.HEIGHT_STEP - 1)) != 0) {
blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight.min is " + minY
+ "; it must be a multiple of " + IrisDimensionType.HEIGHT_STEP + ".");
}
if (logicalHeight < 0) {
blockingErrors.add("Dimension '" + dimensionKey + "' logicalHeight is " + logicalHeight + "; it cannot be negative.");
} else if (logicalHeight > height) {
blockingErrors.add("Dimension '" + dimensionKey + "' logicalHeight is " + logicalHeight
+ "; it cannot be greater than the dimension height of " + height + ".");
}
}
private static JSONObject resolveDimensionHeight(File packFolder, JSONObject dimJson) {
if (!dimJson.has("dimensionHeight") || dimJson.isNull("dimensionHeight")) {
return null;
}
JSONObject inline = dimJson.optJSONObject("dimensionHeight");
if (inline != null) {
return inline;
}
String reference = dimJson.optString("dimensionHeight", null);
if (reference == null || !reference.startsWith("snippet/")) {
return null;
}
// Mirror IrisData's snippet adapter: canonical snippet/range/... is used verbatim; any other
// snippet/... reference is re-rooted under this field's snippet folder.
if (!reference.startsWith("snippet/range/")) {
reference = "snippet/range/" + reference.substring("snippet/".length());
}
File snippet = new File(packFolder, reference + ".json");
if (!snippet.isFile()) {
return null;
}
try {
return new JSONObject(Files.readString(snippet.toPath(), StandardCharsets.UTF_8));
} catch (Throwable e) {
return null;
}
}
}
@@ -55,6 +55,12 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
public final class PackDownloader {
/**
* The branch every download path falls back to when the caller did not name one. Compile-time
* constant so Director {@code @Param(defaultValue = ...)} annotations can reference it.
*/
public static final String DEFAULT_BRANCH = "stable";
private static final String HEAD_REF = "HEAD";
private static final String DEFAULT_OVERWORLD_PACK = "overworld";
private static final String DEFAULT_OVERWORLD_REPOSITORY = "IrisDimensions/overworld";
private static final String DEFAULT_OVERWORLD_REF = "master";
@@ -234,6 +240,13 @@ public final class PackDownloader {
String url = directUrl ? ref : resolveGithubArchiveUrl(repo, ref);
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " ");
File zip = WebCache.getNonCachedFile("pack-" + repo, url, ARCHIVE_LIMITS.maxArchiveBytes());
if ((zip == null || !zip.exists()) && !directUrl && DEFAULT_BRANCH.equals(ref)) {
// A repo without the shared default branch still resolves via its HEAD; one retry, never recursive.
String headUrl = resolveGithubArchiveUrl(repo, HEAD_REF);
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", headUrl)) + " ");
zip = WebCache.getNonCachedFile("pack-" + repo, headUrl, ARCHIVE_LIMITS.maxArchiveBytes());
url = headUrl;
}
File temp = WebCache.getTemp();
File work = new File(temp, "dl-" + UUID.randomUUID());
@@ -436,17 +449,21 @@ public final class PackDownloader {
+ " (required primary dimension is missing).");
}
Optional<IrisData> loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key()));
if (loadedData.isEmpty()) {
loadedData = IrisData.getLoaded(target.toFile());
}
if (loadedData.isPresent()) {
// A registered loader alone is not "active": startup validation registers a loader for
// every visible pack (permanently), which made force-updating any installed pack
// impossible. Live engines only ever attach to detached openRuntime loaders, so the
// gate must go through the engine index, not the cached loader's own engine list.
// Stale cached registrations are closed so the swap cannot race a loader holding the
// old tree.
if (IrisData.hasActiveEngines(target.toFile())) {
sendFeedback(
feedback,
"Pack '" + prepared.key() + "' is active and cannot be replaced safely. Unload its worlds before retrying."
);
return null;
}
IrisData.getLoaded(new File(packsFolder, prepared.key())).ifPresent(IrisData::close);
IrisData.getLoaded(target.toFile()).ifPresent(IrisData::close);
try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) {
publication.commit();
try {
@@ -471,7 +488,7 @@ public final class PackDownloader {
Set<Path> roots = new LinkedHashSet<>();
roots.add(packsRoot);
if (IrisPlatforms.isBound()) {
roots.add(IrisPlatforms.get().dataFolder("packs").toPath().toAbsolutePath().normalize());
roots.add(IrisPlatforms.get().packsFolder().toPath().toAbsolutePath().normalize());
}
for (Path root : roots) {
if (!Files.isDirectory(root)) {
@@ -0,0 +1,72 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pack;
import art.arcane.iris.engine.object.IrisObjectMarker;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.volmlib.util.collection.KSet;
/**
* Shared key collection for the pack packagers. Both the Bukkit re-serializing compiler and the
* modded verbatim-copy compiler must export the complete ambient-spawning graph: object placements
* carry markers, markers carry spawners, spawners carry entities, entities carry loot. These
* helpers keep the two packagers walking placements identically.
*/
public final class PackExportClosure {
private PackExportClosure() {
}
public static KSet<String> collectMarkerKeys(Iterable<IrisObjectPlacement> placements) {
KSet<String> markerKeys = new KSet<>();
if (placements == null) {
return markerKeys;
}
for (IrisObjectPlacement placement : placements) {
if (placement == null || placement.getMarkers() == null) {
continue;
}
for (IrisObjectMarker marker : placement.getMarkers()) {
if (marker == null || marker.getMarker() == null || marker.getMarker().isBlank()) {
continue;
}
markerKeys.add(marker.getMarker());
}
}
return markerKeys;
}
public static KSet<String> collectObjectKeys(Iterable<IrisObjectPlacement> placements) {
KSet<String> objectKeys = new KSet<>();
if (placements == null) {
return objectKeys;
}
for (IrisObjectPlacement placement : placements) {
if (placement == null || placement.getPlace() == null) {
continue;
}
for (String key : placement.getPlace()) {
if (key == null || key.isBlank()) {
continue;
}
objectKeys.add(key);
}
}
return objectKeys;
}
}
@@ -0,0 +1,165 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pack;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* Warns when two generator files with identical content are both referenced by biomes. IrisGenerator's
* equals ignores the load key and IrisComplex buckets generators into a HashSet, so only one of a
* content-identical pair survives; biomes linked to the losing key resolve 0/0 height bounds and
* contribute a flat band at fluid height.
*
* <p>The fingerprint is a conservative approximation of IrisGenerator.equals built from raw JSON
* (sorted keys, canonical number formatting). It can miss a collision where one file spells out a
* value the other leaves defaulted, and it ignores fields the POJO does not declare - acceptable
* for a warning aimed at the copy-the-file-and-forget-the-seed mistake.
*/
final class PackGeneratorDuplicateValidator {
private static final String GENERATOR_SNIPPET_FOLDER = "snippet/generator-layer/";
private PackGeneratorDuplicateValidator() {
}
static List<String> validateDuplicateGenerators(File packFolder) {
List<String> warnings = new ArrayList<>();
if (packFolder == null || !packFolder.isDirectory()) {
return warnings;
}
File generatorsFolder = new File(packFolder, "generators");
if (!generatorsFolder.isDirectory()) {
return warnings;
}
Set<String> referenced = collectReferencedGeneratorKeys(packFolder);
Map<String, List<String>> byFingerprint = new TreeMap<>();
List<File> generatorFiles = PackValidationIo.listJsonRecursive(generatorsFolder);
generatorFiles.sort(Comparator.comparing(File::getPath));
for (File generatorFile : generatorFiles) {
String key = PackValidationIo.deriveKey(generatorsFolder, generatorFile);
String fingerprint;
try {
fingerprint = fingerprint(new JSONObject(Files.readString(generatorFile.toPath(), StandardCharsets.UTF_8)));
} catch (Throwable e) {
continue;
}
byFingerprint.computeIfAbsent(fingerprint, ignored -> new ArrayList<>()).add(key);
}
for (List<String> group : byFingerprint.values()) {
List<String> referencedKeys = group.stream().filter(referenced::contains).sorted().toList();
if (referencedKeys.size() < 2) {
continue;
}
warnings.add("Generators " + String.join(", ", referencedKeys)
+ " have identical content and are both referenced by biomes. Iris buckets generators by value"
+ " (IrisGenerator equals ignores the load key), so only one survives and biomes referencing the"
+ " others get a zero height band. Give each generator a distinct value (for example a different"
+ " seed) or point every biome at a single key.");
}
return warnings;
}
private static Set<String> collectReferencedGeneratorKeys(File packFolder) {
Set<String> referenced = new HashSet<>();
File biomesFolder = new File(packFolder, "biomes");
if (!biomesFolder.isDirectory()) {
return referenced;
}
for (File biomeFile : PackValidationIo.listJsonRecursive(biomesFolder)) {
JSONObject biome;
try {
biome = new JSONObject(Files.readString(biomeFile.toPath(), StandardCharsets.UTF_8));
} catch (Throwable e) {
continue;
}
JSONArray links = biome.optJSONArray("generators");
if (links == null) {
continue;
}
for (int i = 0; i < links.length(); i++) {
JSONObject link = links.optJSONObject(i);
if (link == null) {
String reference = links.optString(i, null);
link = resolveGeneratorLayerSnippet(packFolder, reference);
if (link == null) {
continue;
}
}
referenced.add(link.optString("generator", "default"));
}
}
return referenced;
}
private static JSONObject resolveGeneratorLayerSnippet(File packFolder, String reference) {
if (reference == null || !reference.startsWith("snippet/")) {
return null;
}
String resolved = reference.startsWith(GENERATOR_SNIPPET_FOLDER)
? reference
: GENERATOR_SNIPPET_FOLDER + reference.substring("snippet/".length());
File snippet = new File(packFolder, resolved + ".json");
if (!snippet.isFile()) {
return null;
}
try {
return new JSONObject(Files.readString(snippet.toPath(), StandardCharsets.UTF_8));
} catch (Throwable e) {
return null;
}
}
private static String fingerprint(Object value) {
if (value instanceof JSONObject object) {
Map<String, String> sorted = new TreeMap<>();
for (String key : object.keySet()) {
sorted.put(key, fingerprint(object.get(key)));
}
StringBuilder builder = new StringBuilder("{");
sorted.forEach((key, child) -> builder.append(key).append('=').append(child).append(';'));
return builder.append('}').toString();
}
if (value instanceof JSONArray array) {
StringBuilder builder = new StringBuilder("[");
for (int i = 0; i < array.length(); i++) {
builder.append(fingerprint(array.get(i))).append(';');
}
return builder.append(']').toString();
}
if (value instanceof Number number) {
// Gson deserializes 1 and 1.0 into the same field value; fingerprint them alike.
return Double.toString(number.doubleValue());
}
return String.valueOf(value);
}
}
@@ -34,10 +34,18 @@ final class PackLootValidator {
private PackLootValidator() {
}
static List<String> validateLootGraph(File packFolder) {
record LootGraphIssues(List<String> errors, List<String> warnings) {
LootGraphIssues {
errors = List.copyOf(errors);
warnings = List.copyOf(warnings);
}
}
static LootGraphIssues validateLootGraph(File packFolder) {
List<String> blockingErrors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
if (packFolder == null || !packFolder.isDirectory()) {
return blockingErrors;
return new LootGraphIssues(blockingErrors, warnings);
}
File lootFolder = new File(packFolder, PackValidator.LOOT_FOLDER);
@@ -69,10 +77,10 @@ final class PackLootValidator {
}
String resourceType = PackStructurePlacementValidator.structureHostType(folderName);
String resourceKey = PackValidationIo.deriveKey(resourceFolder, resourceFile);
validateLootReference(resourceType, resourceKey, resource.opt("loot"), lootKeys, blockingErrors);
validateLootReference(resourceType, resourceKey, resource.opt("loot"), lootKeys, blockingErrors, warnings);
}
}
return blockingErrors;
return new LootGraphIssues(blockingErrors, warnings);
}
private static void validateLootTable(String lootKey, JSONObject table, List<String> blockingErrors) {
@@ -158,17 +166,20 @@ final class PackLootValidator {
}
private static void validateLootReference(String resourceType, String resourceKey, Object rawLoot,
Set<String> lootKeys, List<String> blockingErrors) {
Set<String> lootKeys, List<String> blockingErrors, List<String> warnings) {
String path = resourceType + " '" + resourceKey + "'.loot";
if (!(rawLoot instanceof JSONObject reference)) {
blockingErrors.add(path + " must be an object.");
return;
}
boolean clearMode = false;
if (reference.has("mode")) {
Object rawMode = reference.opt("mode");
if (!(rawMode instanceof String mode)
|| !Set.of("ADD", "CLEAR", "REPLACE", "FALLBACK").contains(mode)) {
blockingErrors.add(path + ".mode must be ADD, CLEAR, REPLACE, or FALLBACK.");
} else {
clearMode = "CLEAR".equals(mode);
}
}
if (reference.has("multiplier")) {
@@ -189,6 +200,10 @@ final class PackLootValidator {
blockingErrors.add(path + ".tables must be an array.");
return;
}
if (clearMode && tables.length() > 0) {
warnings.add(path + " uses mode CLEAR and lists " + tables.length()
+ " table(s); CLEAR clears parent tables and contributes no tables of its own, so these entries are dead. Use REPLACE to substitute them.");
}
for (int tableIndex = 0; tableIndex < tables.length(); tableIndex++) {
Object rawTableKey = tables.opt(tableIndex);
if (!(rawTableKey instanceof String tableKey) || tableKey.isBlank()) {
@@ -0,0 +1,154 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pack;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
/**
* Catches the shared IrisStyledRange class default (min 16, max 32) leaking into pack content.
* The POJO cannot distinguish an empty object from an explicit 16/32 after Gson runs, so the raw
* JSON is the only place this mistake is still visible: an objects[].densityStyle of {} places
* 16-32 objects per chunk, and a caveProfile.densityThreshold of {} hollows the whole cave range.
*/
final class PackStyledRangeDefaultValidator {
private static final String STYLE_RANGE_SNIPPET_FOLDER = "snippet/style-range/";
record Validation(List<String> errors, List<String> warnings) {
Validation {
errors = List.copyOf(errors);
warnings = List.copyOf(warnings);
}
}
private PackStyledRangeDefaultValidator() {
}
static Validation validate(File packFolder) {
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
if (packFolder == null || !packFolder.isDirectory()) {
return new Validation(errors, warnings);
}
for (String folderName : PackValidator.STRUCTURE_HOST_FOLDERS) {
File resourceFolder = new File(packFolder, folderName);
if (!resourceFolder.isDirectory()) {
continue;
}
List<File> resourceFiles = PackValidationIo.listJsonRecursive(resourceFolder);
resourceFiles.sort(Comparator.comparing(File::getPath));
String type = hostType(folderName);
for (File resourceFile : resourceFiles) {
String key = PackValidationIo.deriveKey(resourceFolder, resourceFile);
JSONObject json;
try {
json = new JSONObject(Files.readString(resourceFile.toPath(), StandardCharsets.UTF_8));
} catch (Throwable e) {
continue;
}
validateObjectPlacements(packFolder, type, key, json, errors, warnings);
validateCaveProfileThreshold(packFolder, type, key, json.optJSONObject("caveProfile"), errors, warnings);
}
}
return new Validation(errors, warnings);
}
private static void validateObjectPlacements(File packFolder, String type, String key, JSONObject json,
List<String> errors, List<String> warnings) {
JSONArray objects = json.optJSONArray("objects");
if (objects == null) {
return;
}
for (int i = 0; i < objects.length(); i++) {
JSONObject placement = objects.optJSONObject(i);
if (placement == null || !placement.has("densityStyle") || placement.isNull("densityStyle")) {
continue;
}
checkRange(packFolder, placement.opt("densityStyle"),
type + " '" + key + "' objects[" + i + "].densityStyle",
"16-32 objects per chunk", "remove densityStyle to use the scalar density field",
errors, warnings);
}
}
private static void validateCaveProfileThreshold(File packFolder, String type, String key, JSONObject caveProfile,
List<String> errors, List<String> warnings) {
if (caveProfile == null || !caveProfile.has("densityThreshold") || caveProfile.isNull("densityThreshold")) {
return;
}
checkRange(packFolder, caveProfile.opt("densityThreshold"),
type + " '" + key + "' caveProfile.densityThreshold",
"a carve threshold of 16-32, hollowing the entire vertical range",
"remove densityThreshold to keep the cave profile default",
errors, warnings);
}
private static void checkRange(File packFolder, Object raw, String path, String consequence, String removal,
List<String> errors, List<String> warnings) {
JSONObject range = null;
String via = "";
if (raw instanceof JSONObject inline) {
range = inline;
} else if (raw instanceof String reference && reference.startsWith("snippet/")) {
String resolved = reference.startsWith(STYLE_RANGE_SNIPPET_FOLDER)
? reference
: STYLE_RANGE_SNIPPET_FOLDER + reference.substring("snippet/".length());
File snippet = new File(packFolder, resolved + ".json");
if (!snippet.isFile()) {
return; // Missing snippets are reported by the content-key machinery.
}
try {
range = new JSONObject(Files.readString(snippet.toPath(), StandardCharsets.UTF_8));
via = " (via snippet '" + resolved + "')";
} catch (Throwable e) {
return;
}
}
if (range == null) {
return;
}
boolean hasMin = range.has("min") && !range.isNull("min");
boolean hasMax = range.has("max") && !range.isNull("max");
if (!hasMin && !hasMax) {
errors.add(path + via + " omits both min and max, which resolves to the shared IrisStyledRange default of "
+ consequence + ". Set min and max explicitly, or " + removal + ".");
} else if (!hasMin || !hasMax) {
String missing = hasMin ? "max" : "min";
String defaultValue = hasMin ? "32" : "16";
warnings.add(path + via + " omits " + missing + ", which falls back to the shared IrisStyledRange default of "
+ defaultValue + ". Set it explicitly if that is not intended.");
}
}
private static String hostType(String folderName) {
String singular = folderName.endsWith("s") ? folderName.substring(0, folderName.length() - 1) : folderName;
return singular.substring(0, 1).toUpperCase(Locale.ROOT) + singular.substring(1);
}
}
@@ -77,7 +77,9 @@ public final class PackValidator {
PackDimensionValidator.validateDimensions(packFolder, dimensionFiles, blockingErrors, warnings);
blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder));
blockingErrors.addAll(PackLootValidator.validateLootGraph(packFolder));
PackLootValidator.LootGraphIssues lootIssues = PackLootValidator.validateLootGraph(packFolder);
addDistinct(blockingErrors, lootIssues.errors());
addDistinct(warnings, lootIssues.warnings());
blockingErrors.addAll(PackObjectSurfaceValidator.validateRemovedWorldgenFields(packFolder));
blockingErrors.addAll(PackObjectSurfaceValidator.validateObjectSurfaceSupport(packFolder));
blockingErrors.addAll(PackObjectSurfaceValidator.validateUnsupportedStructureTransforms(packFolder));
@@ -96,6 +98,11 @@ public final class PackValidator {
new File(packFolder, "spawners"), new File(packFolder, "entities")));
blockingErrors.addAll(PackSpawnValidator.validateCustomBiomeSpawns(
new File(packFolder, "biomes"), PackSpawnValidator::resolveEntitySpawnCategory));
blockingErrors.addAll(PackBiomeLayerValidator.validateCeilingLayerCounts(new File(packFolder, "biomes")));
PackStyledRangeDefaultValidator.Validation styledRanges = PackStyledRangeDefaultValidator.validate(packFolder);
addDistinct(blockingErrors, styledRanges.errors());
addDistinct(warnings, styledRanges.warnings());
addDistinct(warnings, PackGeneratorDuplicateValidator.validateDuplicateGenerators(packFolder));
// Strict content mode promotes unresolved keys and bad block properties from advisory to blocking. Palette
// -sourced findings are exempt and stay warnings - see ContentKeyValidator.collectContentKeyIssues.
@@ -199,14 +199,17 @@ public class IrisPregenerator {
}
public void start() {
init();
task.iterateAllChunks((_a, _b) -> totalChunks.incrementAndGet());
startTime.set(M.ms());
ticker.start();
checkRegions();
PrecisionStopwatch p = PrecisionStopwatch.start();
boolean completed = false;
// Everything runs inside the try: an early throw from init/checkRegions must still
// reach shutdown(), or the ticker/monitor threads leak and listener.onClose never
// fires leaving a phantom job that suppresses spawns and blocks future pregens.
try {
init();
task.iterateAllChunks((_a, _b) -> totalChunks.incrementAndGet());
startTime.set(M.ms());
ticker.start();
checkRegions();
int[] regionBounds = task.regionBounds();
generator.onRegionBounds(regionBounds[0], regionBounds[1], regionBounds[2], regionBounds[3]);
task.iterateRegions((x, z) -> visitRegion(x, z, true));
@@ -371,10 +374,13 @@ public class IrisPregenerator {
generator.generateChunk(xx, zz, listener);
});
generator.onRegionSubmitted(x, z);
}
if (hit) {
// Exactly once per visited region, on BOTH branches: a cache-completed region that
// never releases its sentinel wedges neighbor eviction for the whole resume
// frontier (allNeighborsDrained can never pass around it).
generator.onRegionSubmitted(x, z);
listener.onRegionGenerated(x, z);
if (saveLatch.flip()) {
@@ -391,7 +397,9 @@ public class IrisPregenerator {
}
generatedRegions.add(pos);
checkRegions();
// No checkRegions() here: re-spiraling every region after every completed region
// was O(regions^2) dead work on the submitter thread (result discarded, and the
// only side effect was CachedPregenMethod faulting plates against eviction).
}
}
@@ -57,7 +57,11 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
public class AsyncPregenMethod implements PregeneratorMethod {
// THREAD_COUNT records the pool's original size while boosted; BOOST_HOLDERS counts live
// jobs holding the boost. Pairing them per instance stops a dying job (whose close can
// land minutes after its replacement started) from shrinking the pool under the live job.
private static final AtomicInteger THREAD_COUNT = new AtomicInteger();
private static final AtomicInteger BOOST_HOLDERS = new AtomicInteger();
private static final int ADAPTIVE_TIMEOUT_STEP = 3;
private static final int ADAPTIVE_RECOVERY_INTERVAL = 8;
private static final long CLOSE_DRAIN_TIMEOUT_SECONDS = 60L;
@@ -102,6 +106,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private final AtomicLong failed = new AtomicLong();
private final AtomicLong lastProgressAt = new AtomicLong(M.ms());
private final AtomicBoolean closing = new AtomicBoolean();
private final AtomicBoolean holdsWorkerBoost = new AtomicBoolean();
private final Object permitMonitor = new Object();
private volatile Engine metricsEngine;
private volatile Mantle cachedMantle;
@@ -738,8 +743,8 @@ public class AsyncPregenMethod implements PregeneratorMethod {
+ ", recommendedCap=" + recommendedRuntimeConcurrencyCap
+ ", urgent=" + urgent
+ ", timeout=" + timeoutSeconds + "s");
if (workerPoolThreads > 0) {
increaseWorkerThreads();
if (workerPoolThreads > 0 && holdsWorkerBoost.compareAndSet(false, true)) {
acquireWorkerThreadBoost();
}
}
@@ -775,7 +780,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
flushAllRemainingChunks();
executor.shutdown();
resetWorkerThreads();
if (holdsWorkerBoost.compareAndSet(true, false)) {
releaseWorkerThreadBoost();
}
} finally {
if (interrupted) {
Thread.currentThread().interrupt();
@@ -945,7 +952,21 @@ public class AsyncPregenMethod implements PregeneratorMethod {
return null;
}
public static void increaseWorkerThreads() {
public static void acquireWorkerThreadBoost() {
if (BOOST_HOLDERS.getAndIncrement() > 0) {
return;
}
increaseWorkerThreads();
}
public static void releaseWorkerThreadBoost() {
if (BOOST_HOLDERS.decrementAndGet() > 0) {
return;
}
resetWorkerThreads();
}
private static void increaseWorkerThreads() {
THREAD_COUNT.updateAndGet(i -> {
if (i > 0) {
return i;
@@ -974,7 +995,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
});
}
public static void resetWorkerThreads() {
private static void resetWorkerThreads() {
THREAD_COUNT.updateAndGet(i -> {
if (i == 0) {
return 0;
@@ -207,18 +207,28 @@ public class MedievalPregenMethod implements PregeneratorMethod {
}
listener.onChunkGenerating(x, z);
if (J.isFolia()) {
futures.add(PaperLib.getChunkAtAsync(world, x, z, true).thenAccept(c -> {
if (c != null) {
lastUse.put(c, M.ms());
}
listener.onChunkGenerated(x, z);
listener.onChunkCleaned(x, z);
}));
return;
}
futures.add(scheduleChunkLoad(x, z, listener));
// Single choke point for failure accounting: without onChunkFailed a lossy run can
// never satisfy allVisitsComplete, so a finished pregen reports as aborted forever.
CompletableFuture<?> chunkFuture = J.isFolia()
? PaperLib.getChunkAtAsync(world, x, z, true).thenAccept(c -> {
if (c != null) {
lastUse.put(c, M.ms());
}
listener.onChunkGenerated(x, z);
try {
listener.onChunkCleaned(x, z);
} catch (Throwable e) {
// Already counted as generated; a throw here must not also count the
// chunk failed through the whenComplete choke point below.
IrisLogging.reportError(e);
}
})
: scheduleChunkLoad(x, z, listener);
futures.add(chunkFuture.whenComplete((r, err) -> {
if (err != null) {
listener.onChunkFailed(x, z);
}
}));
}
private CompletableFuture<?> scheduleChunkLoad(int x, int z, PregenListener listener) {
@@ -294,12 +304,15 @@ public class MedievalPregenMethod implements PregeneratorMethod {
IrisLogging.reportError(error);
}
try {
generateChunkSync(x, z, listener).get(CHUNK_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
future.complete(null);
} catch (Throwable fallbackError) {
future.completeExceptionally(fallbackError);
}
// Chain instead of blocking: parking this MultiBurst worker on a 60s get()
// pinned a shared pool thread per failing chunk.
generateChunkSync(x, z, listener).whenComplete((r, fallbackError) -> {
if (fallbackError != null) {
future.completeExceptionally(fallbackError);
} else {
future.complete(null);
}
});
}
});
@@ -314,7 +327,13 @@ public class MedievalPregenMethod implements PregeneratorMethod {
Chunk chunk = world.getChunkAt(x, z);
lastUse.put(chunk, M.ms());
listener.onChunkGenerated(x, z);
listener.onChunkCleaned(x, z);
try {
listener.onChunkCleaned(x, z);
} catch (Throwable e) {
// The chunk already counted as generated; a throw here must not also count it
// as failed through the whenComplete choke point.
IrisLogging.reportError(e);
}
}
@Override
@@ -131,16 +131,28 @@ public class IrisCodeWorkspace {
project.getPath().mkdirs();
File ws = getCodeWorkspaceFile();
// Render before touching the file: a config-generation failure has nothing to do with
// the existing workspace file, and deleting it before a rethrowing rebuild silently
// destroyed the author's workspace on every boot.
String rendered;
try {
writeIfChanged(ws, createCodeWorkspaceConfig().toString(4));
rendered = createCodeWorkspaceConfig().toString(4);
} catch (Throwable e) {
IrisLogging.reportError(e);
IrisLogging.warn("Could not generate the code workspace config for " + ws.getAbsolutePath() + "; leaving the existing workspace file untouched.");
return false;
}
try {
writeIfChanged(ws, rendered);
return true;
} catch (Throwable e) {
IrisLogging.reportError(e);
IrisLogging.warn("Project invalid: " + ws.getAbsolutePath() + " Re-creating. You may loose some vs-code workspace settings! But not your actual project!");
ws.delete();
try {
IO.writeAll(ws, createCodeWorkspaceConfig());
} catch (IOException e1) {
IO.writeAll(ws, rendered);
} catch (Throwable e1) {
IrisLogging.reportError(e1);
e1.printStackTrace();
}
@@ -29,6 +29,8 @@ import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisEntity;
import art.arcane.iris.engine.object.IrisGenerator;
import art.arcane.iris.engine.object.IrisLootTable;
import art.arcane.iris.core.pack.PackExportClosure;
import art.arcane.iris.engine.object.IrisMarker;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisSpawner;
@@ -49,6 +51,7 @@ import org.zeroturnaround.zip.ZipUtil;
import java.io.File;
import java.io.IOException;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.UUID;
@@ -62,8 +65,25 @@ public class IrisPackageCompiler {
}
public File compilePackage(VolmitSender sender, boolean obfuscate, boolean minify) {
// Detached loader on purpose: obfuscation rewrites the placement 'place' lists on the
// loaded resources, which must never leak into the shared cached pack state a second
// export against a mutated cache produced archives with an empty objects/ directory.
IrisData dm = IrisData.openRuntime(project.getPath());
try {
return compilePackage(dm, sender, obfuscate, minify);
} finally {
dm.close();
}
}
private static <T> void addIfPresent(KSet<T> set, T value) {
if (value != null) {
set.add(value);
}
}
private File compilePackage(IrisData dm, VolmitSender sender, boolean obfuscate, boolean minify) {
String dimm = project.getName();
IrisData dm = IrisData.get(project.getPath());
IrisDimension dimension = dm.getDimensionLoader().load(dimm);
File folder = new File(IrisPlatforms.get().dataFolder(), "exports/" + dimension.getLoadKey());
IO.delete(folder);
@@ -82,20 +102,35 @@ public class IrisPackageCompiler {
KSet<IrisLootTable> loot = new KSet<>();
KSet<IrisBlockData> blocks = new KSet<>();
// KSet is ConcurrentHashMap-backed: both add(null) and remove(null) throw NPE, so a
// failed loader lookup must be filtered at the add site, never stripped afterwards.
for (String i : dm.getBlockLoader().getPossibleKeys()) {
blocks.add(dm.getBlockLoader().load(i));
addIfPresent(blocks, dm.getBlockLoader().load(i));
}
dimension.getRegions().forEach((i) -> regions.add(dm.getRegionLoader().load(i)));
dimension.getLoot().getTables().forEach((i) -> loot.add(dm.getLootLoader().load(i)));
dimension.getRegions().forEach((i) -> addIfPresent(regions, dm.getRegionLoader().load(i)));
dimension.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i)));
regions.forEach((i) -> biomes.addAll(i.getAllBiomes(() -> dm)));
regions.forEach((r) -> r.getLoot().getTables().forEach((i) -> loot.add(dm.getLootLoader().load(i))));
regions.forEach((r) -> r.getEntitySpawners().forEach((sp) -> spawners.add(dm.getSpawnerLoader().load(sp))));
dimension.getEntitySpawners().forEach((sp) -> spawners.add(dm.getSpawnerLoader().load(sp)));
biomes.forEach((i) -> i.getGenerators().forEach((j) -> generators.add(j.getCachedGenerator(() -> dm))));
biomes.forEach((r) -> r.getLoot().getTables().forEach((i) -> loot.add(dm.getLootLoader().load(i))));
biomes.forEach((r) -> r.getEntitySpawners().forEach((sp) -> spawners.add(dm.getSpawnerLoader().load(sp))));
collectSpawnerEntityKeys(spawners).forEach((i) -> entities.add(dm.getEntityLoader().load(i)));
regions.forEach((r) -> r.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i))));
regions.forEach((r) -> r.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp))));
dimension.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp)));
biomes.forEach((i) -> i.getGenerators().forEach((j) -> addIfPresent(generators, j.getCachedGenerator(() -> dm))));
biomes.forEach((r) -> r.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i))));
biomes.forEach((r) -> r.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp))));
KList<IrisObjectPlacement> allPlacements = new KList<>();
regions.forEach((r) -> allPlacements.addAll(r.getObjects()));
biomes.forEach((i) -> allPlacements.addAll(i.getObjects()));
KSet<IrisMarker> markers = new KSet<>();
for (String markerKey : PackExportClosure.collectMarkerKeys(allPlacements)) {
IrisMarker marker = dm.getMarkerLoader().load(markerKey);
if (marker == null) {
continue;
}
markers.add(marker);
marker.getSpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp)));
}
collectSpawnerEntityKeys(spawners).forEach((i) -> addIfPresent(entities, dm.getEntityLoader().load(i)));
entities.forEach((e) -> e.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i))));
Set<String> structureKeys = new LinkedHashSet<>();
collectStructureKeys(structureKeys, dimension.getStructures());
regions.forEach((region) -> collectStructureKeys(structureKeys, region.getStructures()));
@@ -106,25 +141,23 @@ public class IrisPackageCompiler {
StringBuilder c = new StringBuilder();
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_PROJECT_SERIALIZING_OBJECTS));
for (IrisBiome i : biomes) {
for (IrisObjectPlacement j : i.getObjects()) {
b.append(j.hashCode());
KList<String> newNames = new KList<>();
for (IrisObjectPlacement j : allPlacements) {
b.append(j.hashCode());
KList<String> newNames = new KList<>();
for (String k : j.getPlace()) {
if (renameObjects.containsKey(k)) {
newNames.add(renameObjects.get(k));
continue;
}
String name = !obfuscate ? k : UUID.randomUUID().toString().replaceAll("-", "");
b.append(name);
newNames.add(name);
renameObjects.put(k, name);
for (String k : j.getPlace()) {
if (renameObjects.containsKey(k)) {
newNames.add(renameObjects.get(k));
continue;
}
j.setPlace(newNames);
String name = !obfuscate ? k : UUID.randomUUID().toString().replaceAll("-", "");
b.append(name);
newNames.add(name);
renameObjects.put(k, name);
}
j.setPlace(newNames);
}
KMap<String, KList<String>> lookupObjects = renameObjects.flip();
@@ -132,10 +165,18 @@ public class IrisPackageCompiler {
ChronoLatch cl = new ChronoLatch(1000);
O<Integer> ggg = new O<>();
ggg.set(0);
biomes.forEach((i) -> i.getObjects().forEach((j) -> j.getPlace().forEach((k) ->
O<Integer> missingObjects = new O<>();
missingObjects.set(0);
allPlacements.forEach((j) -> j.getPlace().forEach((k) ->
{
try {
File f = dm.getObjectLoader().findFile(lookupObjects.get(k).get(0));
KList<String> sources = lookupObjects.get(k);
File f = sources == null || sources.isEmpty() ? null : dm.getObjectLoader().findFile(sources.get(0));
if (f == null) {
missingObjects.set(missingObjects.get() + 1);
IrisLogging.error("Missing object for placement key " + k);
return;
}
IO.copyFile(f, new File(folder, "objects/" + k + ".iob"));
gb.append(IO.hash(f));
ggg.set(ggg.get() + 1);
@@ -146,9 +187,15 @@ public class IrisPackageCompiler {
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_PROJECT_WROTE_ANOTHER_OBJECTS, MessageArgument.untrusted("g", String.valueOf(g))));
}
} catch (Throwable e) {
missingObjects.set(missingObjects.get() + 1);
IrisLogging.reportError(e);
}
})));
}));
if (missingObjects.get() > 0) {
// A package missing objects must fail loudly, not ship as a clean "compiled".
throw new IllegalStateException(missingObjects.get()
+ " object(s) could not be exported for pack '" + dimm + "'; package compile aborted.");
}
b.append(IO.hash(gb.toString()));
c.append(IO.hash(b.toString()));
@@ -205,6 +252,19 @@ public class IrisPackageCompiler {
b.append(IO.hash(a));
}
// Sorted so package.json.hash stays stable across runs.
for (IrisSpawner i : spawners.stream().sorted(Comparator.comparing(IrisSpawner::getLoadKey)).toList()) {
a = new JSONObject(new Gson().toJson(i)).toString(minify ? 0 : 4);
IO.writeAll(new File(folder, "spawners/" + i.getLoadKey() + ".json"), a);
b.append(IO.hash(a));
}
for (IrisMarker i : markers.stream().sorted(Comparator.comparing(IrisMarker::getLoadKey)).toList()) {
a = new JSONObject(new Gson().toJson(i)).toString(minify ? 0 : 4);
IO.writeAll(new File(folder, "markers/" + i.getLoadKey() + ".json"), a);
b.append(IO.hash(a));
}
c.append(IO.hash(b.toString()));
String finalHash = IO.hash(c.toString());
JSONObject meta = new JSONObject();
@@ -149,7 +149,10 @@ public final class GoldenHashEngine {
if (request.resetMantle()) {
progress.stage(IrisLanguage.plain(RuntimeProgressMessages.CHUNK_STAGE_RESETTING_MANTLE));
resetMantleFull();
if (!resetMantleFull()) {
// A partial reset silently invalidates the capture; never scan over it.
return false;
}
}
List<int[]> targets = ChunkSpiral.centerOut(request.centerChunkX(), request.centerChunkZ(), radius);
@@ -184,29 +187,24 @@ public final class GoldenHashEngine {
}
}
private void resetMantleFull() {
private boolean resetMantleFull() {
Mantle mantle = engine.getMantle().getMantle();
try {
Mantle mantle = engine.getMantle().getMantle();
mantle.saveAll();
File folder = mantle.getDataFolder();
File[] files = folder.listFiles();
if (files != null) {
for (File file : files) {
if (file.isFile()) {
file.delete();
}
}
}
// resetStorage invalidates the IOWorker channel cache before unlinking; deleting
// behind cached channels sent every post-reset plate write to an unlinked inode.
mantle.resetStorage();
feedback.ok(IrisLanguage.plain(
RuntimeProgressMessages.GOLDEN_MANTLE_RESET,
MessageArgument.untrusted("path", folder.getAbsolutePath())
MessageArgument.untrusted("path", mantle.getDataFolder().getAbsolutePath())
));
return true;
} catch (Throwable e) {
IrisLogging.reportError(e);
feedback.warn(IrisLanguage.plain(
RuntimeProgressMessages.GOLDEN_MANTLE_RESET_FAILED,
MessageArgument.untrusted("type", e.getClass().getSimpleName())
));
return false;
}
}
@@ -345,11 +343,18 @@ public final class GoldenHashEngine {
List<String> body = orderedBody(lines);
List<String> mismatches = new ArrayList<>();
// Display strings and diagnosis input are different shapes: diagnose() parses a bare
// "<x> <z>" key, and feeding it a localized sentence aborted the diagnosis whenever
// the FIRST mismatch was a chunk missing from the golden capture.
String firstMismatchKey = null;
for (String line : body) {
int second = line.indexOf(' ', line.indexOf(' ') + 1);
String key = line.substring(0, second);
String golden = goldenChunks.get(key);
if (!line.equals(golden)) {
if (firstMismatchKey == null) {
firstMismatchKey = key;
}
mismatches.add(golden == null
? IrisLanguage.plain(
RuntimeProgressMessages.GOLDEN_MISSING_IN_GOLDEN,
@@ -400,7 +405,7 @@ public final class GoldenHashEngine {
IrisLogging.info("goldenhash MISMATCH: " + mismatches.size() + "/" + body.size() + " -> " + current.getAbsolutePath());
progress.stage(IrisLanguage.plain(RuntimeProgressMessages.CHUNK_STAGE_DIAGNOSING));
diagnose(mismatches.getFirst());
diagnose(firstMismatchKey);
return false;
}
@@ -44,6 +44,8 @@ import org.bukkit.generator.ChunkGenerator.ChunkData;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
public final class InPlaceChunkRegenerator {
private static final int BIOME_STEP = 4;
@@ -111,36 +113,41 @@ public final class InPlaceChunkRegenerator {
inFlight.acquire();
MultiBurst.burst.lazy(() -> {
TerrainChunk buffer = TerrainChunk.create(world);
try {
engine.generate(chunkX << 4, chunkZ << 4, buffer, false);
} catch (Throwable e) {
IrisLogging.reportError(e);
reporter.countApplied(false);
inFlight.release();
allApplied.countDown();
return;
}
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
boolean ok = false;
try {
applyToLiveChunk(chunkX, chunkZ, buffer);
ok = true;
} catch (Throwable e) {
IrisLogging.reportError(e);
} finally {
// Settle exactly once no matter which path fails: an unguarded throw here
// (TerrainChunk.create, runRegion) parked the regen thread on the latch
// forever and stranded the player's boss bar. CAS is required on Folia's
// owned-region path runRegion runs the apply inline before returning.
AtomicBoolean settled = new AtomicBoolean();
Consumer<Boolean> settle = ok -> {
if (settled.compareAndSet(false, true)) {
reporter.countApplied(ok);
inFlight.release();
allApplied.countDown();
}
});
};
try {
TerrainChunk buffer = TerrainChunk.create(world);
engine.generate(chunkX << 4, chunkZ << 4, buffer, false);
if (!scheduled) {
IrisLogging.warn("Regen could not schedule chunk apply at " + chunkX + "," + chunkZ + " in " + world.getName() + ".");
reporter.countApplied(false);
inFlight.release();
allApplied.countDown();
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
boolean ok = false;
try {
applyToLiveChunk(chunkX, chunkZ, buffer);
ok = true;
} catch (Throwable e) {
IrisLogging.reportError(e);
} finally {
settle.accept(ok);
}
});
if (!scheduled) {
IrisLogging.warn("Regen could not schedule chunk apply at " + chunkX + "," + chunkZ + " in " + world.getName() + ".");
settle.accept(false);
}
} catch (Throwable e) {
IrisLogging.reportError(e);
settle.accept(false);
}
});
}
@@ -609,7 +609,7 @@ public final class StudioOpenCoordinator {
});
return operation.whenComplete((result, throwable) -> {
if (world != null) {
IrisToolbelt.endWorldMaintenance(world, "studio-close");
IrisToolbelt.endWorldMaintenance(world, "studio-close", true);
}
});
}
@@ -6,7 +6,6 @@ import art.arcane.iris.core.safeguard.task.Tasks;
import art.arcane.iris.core.safeguard.task.ValueWithDiagnostics;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.scheduling.J;
import java.util.ArrayList;
import java.util.Collections;
@@ -15,7 +14,6 @@ import java.util.List;
import java.util.Map;
public final class IrisSafeguard {
private static volatile boolean forceShutdown = false;
private static Map<Task, ValueWithDiagnostics<Mode>> results = Collections.emptyMap();
private static Map<String, String> context = Collections.emptyMap();
private static Map<String, List<String>> attachment = Collections.emptyMap();
@@ -100,10 +98,6 @@ public final class IrisSafeguard {
}
}
public static boolean isForceShutdown() {
return forceShutdown;
}
private static void warning() {
IrisLogging.warn(C.GOLD + "Iris is running in Warning Mode");
IrisLogging.warn(C.GRAY + "Some startup checks need attention. Review the messages above for tuning suggestions.");
@@ -115,9 +109,9 @@ public final class IrisSafeguard {
IrisLogging.error(C.DARK_RED + "Iris is running in Danger Mode");
IrisLogging.error("");
IrisLogging.error(C.DARK_GRAY + "--==<" + C.RED + " IMPORTANT " + C.DARK_GRAY + ">==--");
IrisLogging.error("Critical startup checks failed. Iris will continue startup in 10 seconds.");
IrisLogging.error("Review and resolve the errors above as soon as possible.");
J.sleep(10000L);
IrisLogging.error("Critical startup checks failed. Review and resolve the errors above as soon as possible.");
// No startup sleep: blocking the boot thread protected nothing world creation and
// player admission are already gated by IrisStartupValidation.
IrisLogging.info("");
}
}
@@ -7,6 +7,7 @@ import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.nms.v1X.NMSBinding1X;
import art.arcane.iris.core.safeguard.Mode;
import art.arcane.iris.core.splash.IrisSplashComposer;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.util.common.misc.getHardware;
import art.arcane.iris.util.project.agent.Agent;
@@ -99,13 +100,21 @@ public final class Tasks {
supportedVersions = BuildConstants.MINECRAFT_VERSION;
}
if (!INMS.isBound()) {
String cause = INMS.bindFailure() == null ? "Unknown NMS bind failure" : INMS.bindFailure().getMessage();
return withDiagnostics(Mode.UNSTABLE,
Diagnostic.Logger.ERROR.create("Server Version"),
Diagnostic.Logger.ERROR.create("- " + cause),
Diagnostic.Logger.ERROR.create("- Iris only supports " + supportedVersions));
}
if (!(INMS.get() instanceof NMSBinding1X)) {
return withDiagnostics(Mode.STABLE);
}
return withDiagnostics(Mode.UNSTABLE,
Diagnostic.Logger.ERROR.create("Server Version"),
Diagnostic.Logger.ERROR.create("- Iris only supports " + supportedVersions));
Diagnostic.Logger.ERROR.create("NMS Disabled"),
Diagnostic.Logger.ERROR.create("- NMS support is disabled (general.disableNMS); Iris world creation is unavailable."));
});
private static final Task INJECTION = Task.of("injection", () -> {
@@ -149,7 +158,13 @@ public final class Tasks {
});
private static final Task JAVA = Task.of("java", () -> {
int version = javaVersion();
int version = IrisSplashComposer.javaVersion();
if (version < 0) {
return withDiagnostics(Mode.WARNING,
Diagnostic.Logger.WARN.create("Java Runtime"),
Diagnostic.Logger.WARN.create("- Java version could not be determined (java.version="
+ System.getProperty("java.version") + ")."));
}
if (version == 25) {
return withDiagnostics(Mode.STABLE);
}
@@ -211,16 +226,4 @@ public final class Tasks {
return new ValueWithDiagnostics<>(mode, diagnostics);
}
private static int javaVersion() {
String version = System.getProperty("java.version");
if (version.startsWith("1.")) {
version = version.substring(2, 3);
} else {
int dot = version.indexOf(".");
if (dot != -1) {
version = version.substring(0, dot);
}
}
return Integer.parseInt(version);
}
}
@@ -67,7 +67,8 @@ public class ExternalDataSVC implements IrisService {
@Override
public void onEnable() {
IrisLogging.info("Loading ExternalDataProvider...");
Bukkit.getPluginManager().registerEvents(this, BukkitPlatform.plugin());
// enable() registers every enabled service as a listener; self-registration here
// doubled every handler invocation.
for (ProviderDefinition definition : BUILT_IN_PROVIDERS) {
activateConfiguredProvider(definition);
@@ -1,6 +1,5 @@
package art.arcane.iris.core.service;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.util.common.plugin.IrisService;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
@@ -10,17 +9,26 @@ import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.Logger;
import org.apache.logging.log4j.message.Message;
import java.util.List;
public class LogFilterSVC implements IrisService, Filter {
private static final String HEIGHTMAP_MISMATCH = "Ignoring heightmap data for chunk";
private static final String RAID_PERSISTENCE = "Could not save data net.minecraft.world.entity.raid.PersistentRaid";
private static final String DUPLICATE_ENTITY_UUID = "UUID of added entity already exists";
private static final KList<String> FILTERS = new KList<>();
// Immutable: check() runs on arbitrary logger threads, and the root logger is JVM-global,
// so a mutable static here was both a CME hazard and grew by three entries per enable.
private static final List<String> FILTERS = List.of(HEIGHTMAP_MISMATCH, RAID_PERSISTENCE, DUPLICATE_ENTITY_UUID);
private boolean installed = false;
public void onEnable() {
FILTERS.add(HEIGHTMAP_MISMATCH, RAID_PERSISTENCE, DUPLICATE_ENTITY_UUID);
if (installed) {
return;
}
((Logger) LogManager.getRootLogger()).addFilter(this);
installed = true;
}
public void initialize() {
@@ -33,6 +41,11 @@ public class LogFilterSVC implements IrisService, Filter {
}
public void onDisable() {
if (!installed) {
return;
}
((Logger) LogManager.getRootLogger()).get().removeFilter(this);
installed = false;
}
public boolean isStarted() {
@@ -116,8 +129,14 @@ public class LogFilterSVC implements IrisService, Filter {
}
private Result check(String string) {
if (FILTERS.stream().anyMatch(string::contains))
return Result.DENY;
if (string == null) {
return Result.NEUTRAL;
}
for (String filter : FILTERS) {
if (string.contains(filter)) {
return Result.DENY;
}
}
return Result.NEUTRAL;
}
}
@@ -24,15 +24,17 @@ import lombok.Getter;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.Iterator;
import java.util.Map;
public class ObjectSVC implements IrisService {
@Getter
private final Deque<Map<Block, BlockData>> undos = new ArrayDeque<>();
// Concurrent + global-thread pipeline: pastes publish via J.runGlobal while the undo
// command arrives on an async pool thread; a plain ArrayDeque was mutated from both.
private final Deque<Map<Block, BlockData>> undos = new ConcurrentLinkedDeque<>();
@Override
@@ -50,7 +52,9 @@ public class ObjectSVC implements IrisService {
}
public void revertChanges(int amount) {
loopChange(amount);
if (!J.runGlobal(() -> loopChange(amount))) {
loopChange(amount);
}
}
private void loopChange(int amount) {
@@ -68,22 +72,26 @@ public class ObjectSVC implements IrisService {
* @param blocks The blocks to remove
*/
private void revert(Map<Block, BlockData> blocks) {
Iterator<Map.Entry<Block, BlockData>> it = blocks.entrySet().iterator();
if (blocks == null || blocks.isEmpty()) {
return;
}
J.s(() -> {
Iterator<Map.Entry<Block, BlockData>> it = blocks.entrySet().iterator();
int amount = 0;
while (it.hasNext()) {
Map.Entry<Block, BlockData> entry = it.next();
BlockData data = entry.getValue();
entry.getKey().setBlockData(data, false);
entry.getKey().setBlockData(entry.getValue(), false);
it.remove();
amount++;
if (amount > 200) {
J.s(() -> revert(blocks), 1);
if (++amount >= 200) {
break;
}
}
if (!blocks.isEmpty()) {
J.s(() -> revert(blocks), 1);
}
});
}
}
@@ -219,7 +219,10 @@ public class StudioSVC implements IrisService {
validatePublishedPack(target);
IrisData installedData;
boolean activeRuntime = previousData != null && !previousData.getEngines().isEmpty();
// Live engines only ever attach to detached openRuntime loaders, never to the
// dataLoaders-cached previousData so the old previousData.getEngines() test was
// always empty and a running world's pack could be swapped with no restart.
boolean activeRuntime = IrisData.hasActiveEngines(target.toFile());
if (previousData == null) {
createdData = IrisData.get(target.toFile());
installedData = createdData;
@@ -780,7 +783,7 @@ public class StudioSVC implements IrisService {
return generator.closeAsync().thenApply(ignored -> true);
})
.whenComplete((unloaded, throwable) -> {
IrisToolbelt.endWorldMaintenance(world, "studio-disable");
IrisToolbelt.endWorldMaintenance(world, "studio-disable", true);
if (throwable != null) {
IrisLogging.reportError("Failed to unload studio world \"" + world.getName()
+ "\" during disable cleanup; startup deletion remains queued.", throwable);
@@ -790,7 +793,7 @@ public class StudioSVC implements IrisService {
}
});
} catch (Throwable e) {
IrisToolbelt.endWorldMaintenance(world, "studio-disable");
IrisToolbelt.endWorldMaintenance(world, "studio-disable", true);
IrisLogging.reportError("Failed to unload studio world \"" + world.getName() + "\" during shutdown cleanup.", e);
}
}
@@ -64,7 +64,10 @@ import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Predicate;
public class TreeSVC implements IrisService {
private boolean block = false;
// Re-entrancy guard for the synthetic StructureGrowEvent this service fires from inside
// its own handler. callEvent dispatches inline on the calling thread, so a ThreadLocal
// is exact; a shared boolean let one region's growth suppress another's on Folia.
private static final ThreadLocal<Boolean> REENTRANT = ThreadLocal.withInitial(() -> false);
@Override
public void onEnable() {
@@ -88,7 +91,7 @@ public class TreeSVC implements IrisService {
*/
@EventHandler(priority = EventPriority.HIGHEST)
public void on(StructureGrowEvent event) {
if (block || event.isCancelled()) {
if (REENTRANT.get() || event.isCancelled()) {
return;
}
IrisLogging.debug(this.getClass().getName() + " received a structure grow event");
@@ -161,11 +164,13 @@ public class TreeSVC implements IrisService {
public void set(int x, int y, int z, PlatformBlockState s) {
BlockData d = (BlockData) s.nativeHandle();
Block b = event.getWorld().getBlockAt(x, y, z);
// Listeners get the post-growth tree, per the StructureGrowEvent contract;
// a fresh snapshot here handed every plugin an all-air "tree".
BlockState state = b.getState();
if (d instanceof IrisCustomData data)
state.setBlockData(data.getBase());
else state.setBlockData(d);
blockStateList.add(b.getState());
blockStateList.add(state);
dataCache.put(new Location(event.getWorld(), x, y, z), d);
}
@@ -245,9 +250,12 @@ public class TreeSVC implements IrisService {
Runnable growTask = () -> {
StructureGrowEvent iGrow = new StructureGrowEvent(event.getLocation(), event.getSpecies(), event.isFromBonemeal(), event.getPlayer(), blockStateList);
block = true;
Bukkit.getServer().getPluginManager().callEvent(iGrow);
block = false;
REENTRANT.set(true);
try {
Bukkit.getServer().getPluginManager().callEvent(iGrow);
} finally {
REENTRANT.set(false);
}
if (!iGrow.isCancelled()) {
for (BlockState state : iGrow.getBlocks()) {
@@ -338,6 +346,11 @@ public class TreeSVC implements IrisService {
public Cuboid getSaplings(Location at, Predicate<BlockData> valid, World world) {
KList<BlockPosition> blockPositions = new KList<>();
grow(at.getWorld(), new BlockPosition(at.getBlockX(), at.getBlockY(), at.getBlockZ()), valid, blockPositions);
if (blockPositions.isEmpty()) {
// No matching saplings (e.g. a giant-mushroom grow event): a 1x1 plane at the
// event location, never the MIN/MAX sentinel cuboid below.
return new Cuboid(at, at);
}
BlockPosition a = new BlockPosition(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE);
BlockPosition b = new BlockPosition(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE);
@@ -24,7 +24,7 @@ public final class IrisSplashComposer {
prefix + style.label(" By: ") + style.value("Volmit Software (Arcane Arts)"),
prefix + style.label(" Web: ") + style.value("VolmitSoftware.com"),
prefix + style.label(" Server: ") + style.value(serverLine),
prefix + style.label(" Java: ") + style.value(String.valueOf(javaVersion())) + style.label(" | Date: ") + style.value(startupDate()),
prefix + style.label(" Java: ") + style.value(javaVersion() < 0 ? "unknown" : String.valueOf(javaVersion())) + style.label(" | Date: ") + style.value(startupDate()),
prefix + style.label(" Commit: ") + style.value(BuildConstants.COMMIT) + style.label("/") + style.value(BuildConstants.ENVIRONMENT),
"",
"",
@@ -54,17 +54,22 @@ public final class IrisSplashComposer {
return lines;
}
/**
* Total parser: java.version values like "25-ea" or "26-internal" have no dot but a
* non-numeric suffix, and a cosmetic banner must never be able to abort startup.
*
* @return the feature version, or -1 when it cannot be determined
*/
public static int javaVersion() {
String version = System.getProperty("java.version");
String version = System.getProperty("java.version", "");
if (version.startsWith("1.")) {
version = version.substring(2, 3);
} else {
int dot = version.indexOf('.');
if (dot != -1) {
version = version.substring(0, dot);
}
version = version.length() > 2 ? version.substring(2, 3) : "";
}
return Integer.parseInt(version);
int end = 0;
while (end < version.length() && Character.isDigit(version.charAt(end))) {
end++;
}
return end == 0 ? -1 : Integer.parseInt(version, 0, end, 10);
}
public static String releaseTrain(String version) {
@@ -90,6 +90,9 @@ public class IrisConverter {
HudSlotClaim barClaim = reportProgress
? BukkitPlatform.hudSlots().open(sender.player(), new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.ACTION_BAR, HudSurface.BOSS_BAR)))
: null;
// try/finally over the whole decode: a throw must never leak the
// self-rescheduling progress task or the HUD claims.
try {
if (mv > 2_000_000) {
largeObject = true;
IrisLogging.info(C.GRAY + "Converting.. " + schem.getName() + " -> " + schem.getName().replace(".schem", ".iob"));
@@ -149,13 +152,15 @@ public class IrisConverter {
}
}
if (i != -1) J.car(i);
if (titleClaim != null) {
titleClaim.release();
}
if (barClaim != null) {
barClaim.release();
BukkitPlatform.hudLanes().hide(sender.player(), "iris:job");
} finally {
if (i != -1) J.car(i);
if (titleClaim != null) {
titleClaim.release();
}
if (barClaim != null) {
barClaim.release();
BukkitPlatform.hudLanes().hide(sender.player(), "iris:job");
}
}
try {
object.shrinkwrap();
@@ -207,6 +207,7 @@ public class IrisCreator {
World world = null;
boolean bukkitRegistered = false;
PlatformChunkGenerator stagedGenerator = null;
try {
reportStudioProgress(0.08D, "resolve_dimension");
reportStudioProgress(0.16D, "prepare_world_pack");
@@ -252,6 +253,7 @@ public class IrisCreator {
if (access == null) {
throw new IrisException("Access is null. Something bad happened.");
}
stagedGenerator = access;
HudSlotClaim createClaim = !benchmark && studioProgressConsumer == null && sender.isPlayer()
? openLoaderClaim("iris:world-create")
: null;
@@ -332,7 +334,7 @@ public class IrisCreator {
}
return world;
} catch (Throwable failure) {
rollbackWorldCreation(worldKey, world, dimensionRoot, bukkitRegistered, failure);
rollbackWorldCreation(worldKey, world, stagedGenerator, dimensionRoot, bukkitRegistered, failure);
if (failure instanceof IrisException irisException) {
throw irisException;
}
@@ -655,12 +657,23 @@ public class IrisCreator {
private void rollbackWorldCreation(
NamespacedKey worldKey,
World createdWorld,
PlatformChunkGenerator stagedGenerator,
File dimensionRoot,
boolean bukkitRegistered,
Throwable failure
) {
World activeWorld = createdWorld == null ? WorldIdentity.resolve(worldKey).orElse(null) : createdWorld;
boolean safeToDelete = activeWorld != null || !containsTimeout(failure);
if (activeWorld == null && stagedGenerator != null) {
// The world never materialized, so no unload path will ever close the staged
// generator; without this it stays registered on WorldInitEvent and attaches a
// second engine when a same-name world is created later.
try {
stagedGenerator.closeAsync().get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (Throwable rollbackFailure) {
failure.addSuppressed(unwrapFailure(rollbackFailure));
}
}
if (activeWorld != null) {
IrisToolbelt.beginWorldMaintenance(activeWorld, "world-create-rollback", true);
try {
@@ -682,6 +695,11 @@ public class IrisCreator {
if (safeToDelete && generator != null) {
generator.closeAsync().get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
// A staged generator the world never bound (e.g. the creation failed after a
// retry re-staged a fresh one) has no unload path either.
if (stagedGenerator != null && stagedGenerator != generator) {
stagedGenerator.closeAsync().get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
} catch (Throwable rollbackFailure) {
Throwable cause = unwrapFailure(rollbackFailure);
failure.addSuppressed(cause);
@@ -690,7 +708,7 @@ public class IrisCreator {
ServerConfigurator.restart("World creation rollback timed out for \"" + name + "\".");
}
} finally {
IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback");
IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback", true);
}
}
@@ -27,15 +27,18 @@ public class IrisReflectiveAPI {
WorldMaintenance.retainMantleDataForSlice(classname);
}
// These delegate to IrisToolbelt so the caller's WORLD Y is rebased into mantle space
// (0..worldHeight). Raw pass-through silently no-opped below Y=0 and read the wrong
// cell everywhere else, diverging from IrisToolbelt and IrisModdedAPI semantics.
public static void setMantleData(World world, int x, int y, int z, Object data) {
IrisToolbelt.access(world).getEngine().getMantle().getMantle().set(x, y, z, data);
IrisToolbelt.setMantleData(world, x, y, z, data);
}
public static void deleteMantleData(World world, int x, int y, int z, Class c) {
IrisToolbelt.access(world).getEngine().getMantle().getMantle().remove(x, y, z, c);
IrisToolbelt.deleteMantleData(world, x, y, z, c);
}
public static Object getMantleData(World world, int x, int y, int z, Class c) {
return IrisToolbelt.access(world).getEngine().getMantle().getMantle().get(x, y, z, c);
return IrisToolbelt.getMantleData(world, x, y, z, c);
}
}
@@ -90,7 +90,7 @@ public class IrisToolbelt {
return null;
}
File packsFolder = IrisPlatforms.get().dataFolder("packs");
File packsFolder = IrisPlatforms.get().packsFolder();
File pack = PackDirectoryResolver.resolveExisting(packsFolder, reference.pack());
if (pack == null) {
File found = findCaseInsensitivePack(packsFolder, reference.pack());
@@ -302,6 +302,13 @@ public class IrisToolbelt {
* @return the pregenerator job (already started)
*/
public static PregeneratorJob pregenerate(PregenTask task, PregeneratorMethod method, Engine engine, boolean cached) {
// Match the modded adapter's contract: a running job is a rejection, never a silent
// kill whose teardown (60s drain + 120s flush) overlaps the new job's generation.
// Callers that genuinely want replacement must stop the old job first
// (PregeneratorJob.shutdownAndWait).
if (PregeneratorJob.getInstance() != null) {
throw new IllegalStateException("An Iris pregeneration job is already running; stop it first with /iris pregen stop.");
}
applyPregenPerformanceProfile(engine);
boolean useCachedWrapper = false;
if (cached && engine != null) {
@@ -588,17 +595,25 @@ public class IrisToolbelt {
}
public static void endWorldMaintenance(World world, String reason) {
endWorldMaintenance(world, reason, false);
}
public static void endWorldMaintenance(World world, String reason, boolean bypassMantleStages) {
if (world == null) {
return;
}
endWorldMaintenance(WorldIdentity.serialize(world), reason);
endWorldMaintenance(WorldIdentity.serialize(world), reason, bypassMantleStages);
}
public static void endWorldMaintenance(String worldName, String reason) {
WorldMaintenance.endWorldMaintenance(worldName, reason);
}
public static void endWorldMaintenance(String worldName, String reason, boolean bypassMantleStages) {
WorldMaintenance.endWorldMaintenance(worldName, reason, bypassMantleStages);
}
public static boolean isWorldMaintenanceActive(World world) {
return world != null && isWorldMaintenanceActive(WorldIdentity.serialize(world));
}
@@ -639,6 +654,14 @@ public class IrisToolbelt {
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) {
PlatformChunkGenerator e = access(world);
if (e == null || data == null) {
return;
}
e.getEngine().getMantle().getMantle().set(x, y - world.getMinHeight(), z, data);
}
public static boolean removeWorld(World world) throws IOException {
return IrisCreator.removeFromBukkitYml(IrisWorldStorage.logicalName(world));
}
@@ -1,17 +1,16 @@
package art.arcane.iris.core.tools;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.engine.mantle.MantleSliceRetention;
import art.arcane.iris.spi.IrisLogging;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public final class WorldMaintenance {
private static final Map<String, AtomicInteger> worldMaintenanceDepth = new ConcurrentHashMap<>();
private static final Map<String, AtomicInteger> worldMaintenanceMantleBypassDepth = new ConcurrentHashMap<>();
private static final Set<String> retainedMantleSlices = ConcurrentHashMap.newKeySet();
private WorldMaintenance() {
}
@@ -37,6 +36,10 @@ public final class WorldMaintenance {
}
public static void endWorldMaintenance(String worldName, String reason) {
endWorldMaintenance(worldName, reason, false);
}
public static void endWorldMaintenance(String worldName, String reason, boolean bypassMantleStages) {
if (worldName == null) {
return;
}
@@ -52,13 +55,17 @@ public final class WorldMaintenance {
depth = 0;
}
AtomicInteger bypassCounter = worldMaintenanceMantleBypassDepth.get(worldName);
// 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.
int bypassDepth = 0;
if (bypassCounter != null) {
bypassDepth = bypassCounter.decrementAndGet();
if (bypassDepth <= 0) {
worldMaintenanceMantleBypassDepth.remove(worldName, bypassCounter);
bypassDepth = 0;
if (bypassMantleStages) {
AtomicInteger bypassCounter = worldMaintenanceMantleBypassDepth.get(worldName);
if (bypassCounter != null) {
bypassDepth = bypassCounter.decrementAndGet();
if (bypassDepth <= 0) {
worldMaintenanceMantleBypassDepth.remove(worldName, bypassCounter);
bypassDepth = 0;
}
}
}
@@ -88,14 +95,10 @@ public final class WorldMaintenance {
}
public static void retainMantleDataForSlice(String className) {
if (className == null) {
return;
}
retainedMantleSlices.add(className);
MantleSliceRetention.retain(className);
}
public static boolean isRetainingMantleDataForSlice(String className) {
return className != null && retainedMantleSlices.contains(className);
return MantleSliceRetention.isRetained(className);
}
}
@@ -18,10 +18,14 @@
package art.arcane.iris.engine;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
@@ -46,8 +50,10 @@ final class EngineBackgroundTasks {
boolean scheduleTrackedTask(Runnable task) {
synchronized (backgroundTaskLock) {
backgroundTasks.removeIf(tracked -> tracked.completion.isDone()
&& !tracked.completion.isCompletedExceptionally());
// A finished task is not outstanding work, however it finished. Retaining failed
// entries made the NEXT transition's drain re-report a long-settled failure and
// blocked close() from ever marking the engine closed.
backgroundTasks.removeIf(tracked -> tracked.completion.isDone());
if (!backgroundTaskAdmission) {
return false;
}
@@ -60,6 +66,10 @@ final class EngineBackgroundTasks {
return null;
} catch (Throwable exception) {
tracked.completion.completeExceptionally(exception);
// J.a(Callable) routes through a future nobody reads; report here or the
// failure is invisible.
IrisLogging.reportError(exception);
IrisLogging.error("Iris background task failed.");
throw propagate(exception);
}
});
@@ -79,6 +89,15 @@ final class EngineBackgroundTasks {
}
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(BACKGROUND_TASK_TIMEOUT_MILLIS);
Throwable failure = null;
// Settled-set snapshot taken ONCE at drain entry: a per-iteration isDone() sample
// would also suppress tasks that failed while the drain was blocked on an earlier
// task, letting a real in-flight failure pass the transition unreported.
Set<TrackedBackgroundTask> settledAtEntry = Collections.newSetFromMap(new IdentityHashMap<>());
for (TrackedBackgroundTask task : tasks) {
if (task.completion.isDone()) {
settledAtEntry.add(task);
}
}
for (TrackedBackgroundTask task : tasks) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0L) {
@@ -86,6 +105,7 @@ final class EngineBackgroundTasks {
failure = appendFailure(failure, new TimeoutException("Timed out waiting for Iris background tasks during " + reason + "."));
continue;
}
boolean alreadyDone = settledAtEntry.contains(task);
try {
task.completion.get(remaining, TimeUnit.NANOSECONDS);
} catch (InterruptedException e) {
@@ -94,7 +114,13 @@ final class EngineBackgroundTasks {
failure = appendFailure(failure, e);
} catch (ExecutionException | TimeoutException e) {
cancelBackgroundTask(task, reason);
failure = appendFailure(failure, e);
// A task that had settled before this drain began was already reported when it
// failed; only genuinely in-flight failures may poison this transition.
if (!alreadyDone) {
failure = appendFailure(failure, e);
} else {
IrisLogging.debug("Ignoring pre-settled background task failure during " + reason + ".");
}
}
}
boolean complete;
@@ -33,6 +33,8 @@ import art.arcane.iris.spi.IrisServices;
* having succeeded so a partially released engine never reports itself as closed.
*/
final class EngineShutdownSequence {
private static final long CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS = 5000L;
private final IrisEngine engine;
private boolean runtimeReleased;
private boolean targetReleased;
@@ -55,11 +57,50 @@ final class EngineShutdownSequence {
engine.getClosing().set(true);
engine.backgroundTasks.closeBackgroundTaskAdmission();
EngineTickRegistry.unregisterTicking(engine);
engine.getPlatformHooks().shutdownPregenerator(engine);
// Best-effort like every other step: a pregen join timeout must not escape the
// synchronized block before anything is saved or released. On failure, save what
// can be saved and leave the close incomplete-but-retryable (a live pregen writer
// may still hold the mantle, so the releases are skipped).
Throwable pregenFailure = runCleanup(null, () -> engine.getPlatformHooks().shutdownPregenerator(engine));
if (pregenFailure != null) {
engine.lifecycleState = LifecycleState.FAILED;
failure = pregenFailure;
failure = runCleanup(failure, engine::savePrefetchOnce);
failure = runCleanup(failure, engine::saveEngineData);
failure = runCleanup(failure, () -> engine.getMantle().saveAllNow());
reportIncompleteClose(failure);
return;
}
Throwable drainFailure = null;
try {
engine.getGenerationSessions().sealAndAwait("close", IrisEngine.SESSION_DRAIN_TIMEOUT_MILLIS, true);
} catch (GenerationSessionException e) {
throw new IllegalStateException("Failed to drain Iris generation for close.", e);
drainFailure = e;
}
if (drainFailure != null) {
// A drain timeout must not abandon teardown: the world manager is the lease
// producer, so stop it first, then re-drain briefly.
IrisLogging.warn("Iris generation did not drain for close on " + engine.getWorld().name()
+ "; stopping the world manager and retrying.");
Throwable managerFailure = engine.runtime == null
? null
: runCleanup(null, engine.runtime.worldManager()::close);
try {
engine.getGenerationSessions().sealAndAwait("close-retry", CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS, true);
drainFailure = null;
} catch (GenerationSessionException e) {
drainFailure = appendFailure(drainFailure, e);
}
drainFailure = appendFailure(drainFailure, managerFailure);
}
if (drainFailure != null) {
// A live lease may be mid-write, so the mantle must not be closed at it but
// dirty plates can still be flushed (saveAll is synchronized) so terrain since
// the last periodic save is not lost. The close stays incomplete and retryable.
engine.lifecycleState = LifecycleState.FAILED;
failure = runCleanup(drainFailure, () -> engine.getMantle().saveAllNow());
reportIncompleteClose(failure);
return;
}
BackgroundTaskDrain backgroundDrain = engine.backgroundTasks.drainBackgroundTasks("close");
@@ -108,13 +149,17 @@ final class EngineShutdownSequence {
}
}
if (failure != null) {
IrisLogging.error("Iris engine shutdown remains incomplete after cleanup failures for " + engine.getWorld().name() + ".");
IrisLogging.reportError(failure);
failure.printStackTrace();
throw new IllegalStateException("Iris engine shutdown remains incomplete after cleanup failures.", failure);
reportIncompleteClose(failure);
}
}
private void reportIncompleteClose(Throwable failure) {
IrisLogging.error("Iris engine shutdown remains incomplete after cleanup failures for " + engine.getWorld().name() + ".");
IrisLogging.reportError(failure);
failure.printStackTrace();
throw new IllegalStateException("Iris engine shutdown remains incomplete after cleanup failures.", failure);
}
void cleanupFailedConstruction(Throwable original) {
engine.getClosing().set(true);
engine.backgroundTasks.closeBackgroundTaskAdmission();
@@ -118,7 +118,11 @@ public class IrisComplex implements DataProvider {
private IrisRegion focusRegion;
private Map<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> generatorBounds;
private Set<IrisBiome> generatorBiomes;
private final Map<IrisBiome, ChildSelectionPlan> childSelectionPlans = Collections.synchronizedMap(new IdentityHashMap<>());
// Copy-on-write: reads happen per column on every burst thread; the synchronizedMap
// monitor was taken on every HIT. Writes are once per biome and bounded, so a fresh map
// per insert is cheap. Identity keying is load-bearing (IrisBiome is mutable/value-hashed).
private volatile IdentityHashMap<IrisBiome, ChildSelectionPlan> childSelectionPlans = new IdentityHashMap<>();
private final Object childSelectionPlanLock = new Object();
public IrisComplex(Engine engine) {
this(engine, false);
@@ -169,7 +173,7 @@ public class IrisComplex implements DataProvider {
generatorBounds = buildGeneratorBounds(engine);
KList<IrisShapedGeneratorStyle> overlayNoise = engine.getDimension().getOverlayNoise();
overlayStream = overlayNoise.isEmpty()
? ProceduralStream.ofDouble((x, z) -> 0.0D).waste("Overlay Stream")
? ProceduralStream.ofDouble((x, z) -> 0.0D)
: ProceduralStream.ofDouble((x, z) -> {
double value = 0D;
@@ -178,22 +182,22 @@ public class IrisComplex implements DataProvider {
}
return value;
}).waste("Overlay Stream");
});
rockStream = engine.getDimension().getRockPalette().getLayerGenerator(rng.nextParallelRNG(45), data).stream()
.select(engine.getDimension().getRockPalette().getBlockData(data)).waste("Rock Stream");
.select(engine.getDimension().getRockPalette().getBlockData(data));
fluidStream = engine.getDimension().getFluidPalette().getLayerGenerator(rng.nextParallelRNG(78), data).stream()
.select(engine.getDimension().getFluidPalette().getBlockData(data)).waste("Fluid Stream");
.select(engine.getDimension().getFluidPalette().getBlockData(data));
regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream()
.zoom(engine.getDimension().getRegionZoom()).waste("Region Style");
regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE).waste("Region Identity Stream");
.zoom(engine.getDimension().getRegionZoom());
regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE);
regionStream = focusRegion != null ?
ProceduralStream.of((x, z) -> focusRegion,
Interpolated.of(a -> 0D, a -> focusRegion))
: regionStyleStream
.selectRarity(data.getRegionLoader().loadAll(engine.getDimension().getRegions()))
.cache2D("regionStream", engine, cacheSize).waste("Region Stream");
.cache2D("regionStream", engine, cacheSize);
regionIDStream = regionIdentityStream.convertCached((i) -> new UUID(Double.doubleToLongBits(i),
String.valueOf(i * 38445).hashCode() * 3245556666L)).waste("Region ID Stream");
String.valueOf(i * 38445).hashCode() * 3245556666L));
caveBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r)
-> engine.getDimension().getCaveBiomeStyle().create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream()
@@ -201,7 +205,7 @@ public class IrisComplex implements DataProvider {
.zoom(r.getCaveBiomeZoom())
.selectRarity(loadInferredBiomes(r.getCaveBiomes(), InferredType.CAVE))
.onNull(emptyBiome)
).convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize).waste("Cave Biome Stream");
).convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.CAVE, caveBiomeStream);
landBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r)
@@ -211,7 +215,7 @@ public class IrisComplex implements DataProvider {
.zoom(r.getLandBiomeZoom())
.selectRarity(loadInferredBiomes(r.getLandBiomes(), InferredType.LAND))
).convertAware2D(ProceduralStream::get)
.cache2D("landBiomeStream", engine, cacheSize).waste("Land Biome Stream");
.cache2D("landBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.LAND, landBiomeStream);
seaBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r)
@@ -221,7 +225,7 @@ public class IrisComplex implements DataProvider {
.zoom(r.getSeaBiomeZoom())
.selectRarity(loadInferredBiomes(r.getSeaBiomes(), InferredType.SEA))
).convertAware2D(ProceduralStream::get)
.cache2D("seaBiomeStream", engine, cacheSize).waste("Sea Biome Stream");
.cache2D("seaBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.SEA, seaBiomeStream);
shoreBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r)
@@ -229,60 +233,60 @@ public class IrisComplex implements DataProvider {
.zoom(engine.getDimension().getBiomeZoom())
.zoom(r.getShoreBiomeZoom())
.selectRarity(loadInferredBiomes(r.getShoreBiomes(), InferredType.SHORE))
).convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize).waste("Shore Biome Stream");
).convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.SHORE, shoreBiomeStream);
bridgeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome.getInferredType(),
Interpolated.of(a -> 0D, a -> focusBiome.getInferredType())) :
engine.getDimension().getContinentalStyle().create(rng.nextParallelRNG(234234565), getData())
.bake().scale(1D / engine.getDimension().getContinentZoom()).bake().stream()
.convert((v) -> v >= engine.getDimension().getLandChance() ? InferredType.SEA : InferredType.LAND)
.cache2D("bridgeStream", engine, cacheSize).waste("Bridge Stream");
.cache2D("bridgeStream", engine, cacheSize);
baseBiomeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome,
Interpolated.of(a -> 0D, a -> focusBiome)) :
bridgeStream.convertAware2D((t, x, z) -> inferredStreams.get(t).get(x, z))
.convertAware2D(this::implode)
.cache2D("baseBiomeStream", engine, cacheSize).waste("Base Biome Stream");
.cache2D("baseBiomeStream", engine, cacheSize);
heightStream = ProceduralStream.of((x, z) -> {
IrisBiome b = focusBiome != null ? focusBiome : baseBiomeStream.get(x, z);
return getHeight(engine, b, x, z, engine.getSeedManager().getHeight());
}, Interpolated.DOUBLE).cache2DDouble("heightStream", engine, cacheSize).waste("Height Stream");
}, Interpolated.DOUBLE).cache2DDouble("heightStream", engine, cacheSize);
roundedHeighteightStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.round().waste("Rounded Height Stream");
.round();
slopeStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.slope(3).cache2DDouble("slopeStream", engine, cacheSize).waste("Slope Stream");
.slope(3).cache2DDouble("slopeStream", engine, cacheSize);
trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
b -> focusBiome))
.cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream
.convertAware2D((h, x, z) ->
fixBiomeType(h, baseBiomeStream.get(x, z),
regionStream.contextInjecting(engine, (c, xx, zz) -> c.getRegion().get(xx, zz)).get(x, z), x, z, fluidHeight))
.cache2D("trueBiomeStream", engine, cacheSize).waste("True Biome Stream");
.cache2D("trueBiomeStream", engine, cacheSize);
trueBiomeDerivativeStream = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())).cache2D("trueBiomeDerivativeStream", engine, cacheSize).waste("True Biome Derivative Stream");
.convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())).cache2D("trueBiomeDerivativeStream", engine, cacheSize);
heightFluidStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.max(fluidHeight).cache2DDouble("heightFluidStream", engine, cacheSize).waste("Height Fluid Stream");
maxHeightStream = ProceduralStream.ofDouble((x, z) -> height).waste("Max Height Stream");
.max(fluidHeight).cache2DDouble("heightFluidStream", engine, cacheSize);
maxHeightStream = ProceduralStream.ofDouble((x, z) -> height);
terrainSurfaceDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainSurfaceDecoration", engine, cacheSize).waste("Surface Decoration Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainSurfaceDecoration", engine, cacheSize);
terrainCeilingDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)).cache2D("terrainCeilingDecoration", engine, cacheSize).waste("Ceiling Decoration Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)).cache2D("terrainCeilingDecoration", engine, cacheSize);
terrainCaveSurfaceDecoration = caveBiomeStream.contextInjecting(engine, (c, x, z) -> c.getCave().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainCaveSurfaceDecoration", engine, cacheSize).waste("Cave Surface Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainCaveSurfaceDecoration", engine, cacheSize);
terrainCaveCeilingDecoration = caveBiomeStream.contextInjecting(engine, (c, x, z) -> c.getCave().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)).cache2D("terrainCaveCeilingDecoration", engine, cacheSize).waste("Cave Ceiling Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)).cache2D("terrainCaveCeilingDecoration", engine, cacheSize);
shoreSurfaceDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SHORE_LINE)).cache2D("shoreSurfaceDecoration", engine, cacheSize).waste("Shore Surface Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SHORE_LINE)).cache2D("shoreSurfaceDecoration", engine, cacheSize);
seaSurfaceDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_SURFACE)).cache2D("seaSurfaceDecoration", engine, cacheSize).waste("Sea Surface Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_SURFACE)).cache2D("seaSurfaceDecoration", engine, cacheSize);
seaFloorDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_FLOOR)).cache2D("seaFloorDecoration", engine, cacheSize).waste("Sea Floor Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_FLOOR)).cache2D("seaFloorDecoration", engine, cacheSize);
baseBiomeIDStream = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, x, z) -> {
UUID d = regionIDStream.get(x, z);
return new UUID(b.getLoadKey().hashCode() * 818223L,
d.hashCode());
})
.cache2D("", engine, cacheSize).waste("Biome ID Stream");
.cache2D("", engine, cacheSize);
//@done
}
@@ -598,7 +602,7 @@ public class IrisComplex implements DataProvider {
return cachedPlan;
}
synchronized (childSelectionPlans) {
synchronized (childSelectionPlanLock) {
ChildSelectionPlan synchronizedPlan = childSelectionPlans.get(biome);
if (synchronizedPlan != null) {
return synchronizedPlan;
@@ -614,7 +618,9 @@ public class IrisComplex implements DataProvider {
options.add(biome);
ChildSelectionPlan createdPlan = ChildSelectionPlan.create(options);
childSelectionPlans.put(biome, createdPlan);
IdentityHashMap<IrisBiome, ChildSelectionPlan> next = new IdentityHashMap<>(childSelectionPlans);
next.put(biome, createdPlan);
childSelectionPlans = next;
return createdPlan;
}
}
@@ -389,12 +389,21 @@ public class IrisEngineMantle implements EngineMantle {
return worker.read(name, (regionName, in) ->
TectonicPlate.read(worldHeight, in, regionName.startsWith("pv."), adapter, hooks));
} finally {
if (TectonicPlate.hasError() && IrisSettings.get().getGeneral().isDumpMantleOnError()) {
File dump = IrisPlatforms.get().dataFile("dump", name + ".bin");
worker.dumpDecoded(name, dump.toPath());
} else {
IrisLogging.debug("Read Tectonic Plate " + C.DARK_GREEN + name + C.RED + " in " + Form.duration(stopwatch.getMilliseconds(), 2));
// hasError() is a consuming ThreadLocal read: evaluate exactly once. The
// dump must never throw out of this finally that would replace the
// successfully parsed plate with the dump failure, and the caller would
// overwrite 1024 good chunks with a fresh empty plate.
boolean errored = TectonicPlate.hasError();
if (errored && IrisSettings.get().getGeneral().isDumpMantleOnError()) {
try {
File dump = IrisPlatforms.get().dataFile("dump", name + ".bin");
worker.dumpDecoded(name, dump.toPath());
} catch (Throwable dumpFailure) {
IrisLogging.warn("Failed to dump mantle region " + name + " for diagnostics");
IrisLogging.reportError(dumpFailure);
}
}
IrisLogging.debug("Read Tectonic Plate " + C.DARK_GREEN + name + C.RED + " in " + Form.duration(stopwatch.getMilliseconds(), 2));
}
}
@@ -519,18 +519,7 @@ final class WorldEntitySpawner {
}
private KList<IrisEntitySpawn> spawnRandomly(List<IrisEntitySpawn> types) {
KList<IrisEntitySpawn> rarityTypes = new KList<>();
int totalRarity = 0;
for (IrisEntitySpawn i : types) {
totalRarity += IRare.get(i);
}
for (IrisEntitySpawn i : types) {
rarityTypes.addMultiple(i, totalRarity / IRare.get(i));
}
return rarityTypes;
return IRare.expandWeighted(types);
}
@Data

Some files were not shown because too many files have changed in this diff Show More