mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
Full audit fixpass: 93 defect fixes, 14 perf wins, vestigial cleanup
This commit is contained in:
+73
-10
@@ -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);
|
||||
Object generator = generatorType.isInstance(constructorGenerator) ? constructorGenerator : null;
|
||||
if (generator == null) {
|
||||
generator = Class.forName("art.arcane.iris.core.lifecycle.WorldLifecycleStaging", true, pluginClassLoader)
|
||||
.getDeclaredMethod("consumeStemGenerator", String.class)
|
||||
.invoke(null, levelId);
|
||||
org.bukkit.plugin.Plugin irisPlugin = Bukkit.getPluginManager().getPlugin("Iris");
|
||||
if (irisPlugin == null) {
|
||||
return;
|
||||
}
|
||||
if (!(generator instanceof ChunkGenerator gen) || !gen.getClass().getPackageName().startsWith("art.arcane.iris")) {
|
||||
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 = stagingType
|
||||
.getDeclaredMethod("consumeStemGenerator", String.class)
|
||||
.invoke(null, levelId);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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.");
|
||||
|
||||
+43
-9
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+3
-2
@@ -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);
|
||||
|
||||
+13
-4
@@ -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));
|
||||
|
||||
+46
-43
@@ -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);
|
||||
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);
|
||||
}
|
||||
|
||||
+59
-17
@@ -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,33 +176,71 @@ 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);
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_FOUND_NEARBY_MARKERS, MessageArgument.untrusted("value", v.get()), MessageArgument.untrusted("marker", marker)));
|
||||
} else {
|
||||
if (!IrisToolbelt.isIrisWorld(world)) {
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IRIS_WORLDS_ONLY_2));
|
||||
return;
|
||||
}
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
|
||||
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", 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+16
-2
@@ -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,14 +30,27 @@ 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()) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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();",
|
||||
|
||||
+20
@@ -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"));
|
||||
}
|
||||
}
|
||||
+63
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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");
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+2
@@ -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) {
|
||||
|
||||
+16
@@ -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);
|
||||
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();
|
||||
|
||||
+2
-1
@@ -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();
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+15
-5
@@ -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,11 +202,19 @@ public final class ModdedWorldEngines {
|
||||
ServerLevel level = entry.getKey();
|
||||
Engine engine = entry.getValue();
|
||||
try {
|
||||
// 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) {
|
||||
LOGGER.error("Iris engine close failed for {}", level.dimension().identifier(), e);
|
||||
|
||||
+4
-18
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+4
-3
@@ -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) ->
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
+23
@@ -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();
|
||||
}
|
||||
|
||||
+10
@@ -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");
|
||||
|
||||
+8
-3
@@ -291,7 +291,11 @@ 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);
|
||||
MinecraftServer server = source.getServer();
|
||||
J.a(() -> {
|
||||
File parent = file.getParentFile();
|
||||
if (parent != null) {
|
||||
parent.mkdirs();
|
||||
@@ -300,8 +304,8 @@ public final class ModdedObjectCommands {
|
||||
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;
|
||||
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) {
|
||||
@@ -313,8 +317,9 @@ public final class ModdedObjectCommands {
|
||||
} 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)));
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -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()) {
|
||||
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();
|
||||
}
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+40
-6
@@ -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");
|
||||
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());
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
+3
-2
@@ -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()) {
|
||||
|
||||
+12
-1
@@ -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();
|
||||
}
|
||||
|
||||
+31
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
|
||||
+106
@@ -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"));
|
||||
}
|
||||
}
|
||||
+75
@@ -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) {
|
||||
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,11 +552,13 @@ public final class DatapackIngestService {
|
||||
ServerConfigurator.requireDatapackRestart();
|
||||
}
|
||||
}
|
||||
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.LoadedDatapackRuntimeInvalidation invalidation =
|
||||
ServerConfigurator.invalidateLoadedDatapackRuntime();
|
||||
boolean removed;
|
||||
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);
|
||||
|
||||
// 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) {
|
||||
connection.disconnect();
|
||||
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);
|
||||
}
|
||||
} 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()) {
|
||||
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;
|
||||
|
||||
Location blockLocation = block.toBlock(world).getLocation();
|
||||
Runnable revealTask = () -> {
|
||||
BlockSignal.of(world, block.getX(), block.getY(), block.getZ(), 10);
|
||||
if (M.r(0.25)) {
|
||||
world.playSound(block.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);
|
||||
}
|
||||
/**
|
||||
* 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();
|
||||
|
||||
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));
|
||||
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);
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
|
||||
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(first.toBlock(world).getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 1f, RNG.r.f(0.2f, 2f));
|
||||
}
|
||||
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 {
|
||||
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()) {
|
||||
batch = order;
|
||||
order = new KList<>();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
for (Runnable r : batch) {
|
||||
try {
|
||||
order.pop().run();
|
||||
r.run();
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
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,13 +452,20 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
|
||||
double mk = mscale;
|
||||
double mkd = scale;
|
||||
e.submit(() -> {
|
||||
// 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());
|
||||
working.remove(key);
|
||||
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,13 +480,18 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
|
||||
double mk = mscale;
|
||||
double mkd = scale;
|
||||
eh.submit(() -> {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
} 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) {
|
||||
if (errorThrottle.flip()) {
|
||||
IrisLogging.error("Could not get selection");
|
||||
e.printStackTrace();
|
||||
IrisLogging.reportError(e);
|
||||
active.reset();
|
||||
active.aquire(() -> false);
|
||||
}
|
||||
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,7 +160,8 @@ 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 {
|
||||
schemaBuildExecutor().execute(() -> {
|
||||
try {
|
||||
IO.writeAll(a, new SchemaBuilder(objectClass, manager).construct().toString(4));
|
||||
} catch (Throwable e) {
|
||||
@@ -154,6 +170,12 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
|
||||
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() {
|
||||
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();
|
||||
PrecisionStopwatch p = PrecisionStopwatch.start();
|
||||
boolean completed = false;
|
||||
try {
|
||||
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).
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+26
-5
@@ -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;
|
||||
|
||||
+29
-10
@@ -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 -> {
|
||||
// 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);
|
||||
}));
|
||||
return;
|
||||
} 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);
|
||||
}
|
||||
|
||||
futures.add(scheduleChunkLoad(x, z, listener));
|
||||
})
|
||||
: 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) {
|
||||
// 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);
|
||||
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,8 +141,7 @@ 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()) {
|
||||
for (IrisObjectPlacement j : allPlacements) {
|
||||
b.append(j.hashCode());
|
||||
KList<String> newNames = new KList<>();
|
||||
|
||||
@@ -125,17 +159,24 @@ public class IrisPackageCompiler {
|
||||
|
||||
j.setPlace(newNames);
|
||||
}
|
||||
}
|
||||
|
||||
KMap<String, KList<String>> lookupObjects = renameObjects.flip();
|
||||
StringBuilder gb = new StringBuilder();
|
||||
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() {
|
||||
try {
|
||||
private boolean resetMantleFull() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
// 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,16 +113,21 @@ 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);
|
||||
// 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();
|
||||
return;
|
||||
}
|
||||
};
|
||||
try {
|
||||
TerrainChunk buffer = TerrainChunk.create(world);
|
||||
engine.generate(chunkX << 4, chunkZ << 4, buffer, false);
|
||||
|
||||
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
|
||||
boolean ok = false;
|
||||
@@ -130,17 +137,17 @@ public final class InPlaceChunkRegenerator {
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
} finally {
|
||||
reporter.countApplied(ok);
|
||||
inFlight.release();
|
||||
allApplied.countDown();
|
||||
settle.accept(ok);
|
||||
}
|
||||
});
|
||||
|
||||
if (!scheduled) {
|
||||
IrisLogging.warn("Regen could not schedule chunk apply at " + chunkX + "," + chunkZ + " in " + world.getName() + ".");
|
||||
reporter.countApplied(false);
|
||||
inFlight.release();
|
||||
allApplied.countDown();
|
||||
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))
|
||||
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,8 +52,10 @@ public class ObjectSVC implements IrisService {
|
||||
}
|
||||
|
||||
public void revertChanges(int amount) {
|
||||
if (!J.runGlobal(() -> loopChange(amount))) {
|
||||
loopChange(amount);
|
||||
}
|
||||
}
|
||||
|
||||
private void loopChange(int amount) {
|
||||
if (undos.size() > 0) {
|
||||
@@ -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;
|
||||
REENTRANT.set(true);
|
||||
try {
|
||||
Bukkit.getServer().getPluginManager().callEvent(iGrow);
|
||||
block = false;
|
||||
} 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) : "";
|
||||
}
|
||||
int end = 0;
|
||||
while (end < version.length() && Character.isDigit(version.charAt(end))) {
|
||||
end++;
|
||||
}
|
||||
return Integer.parseInt(version);
|
||||
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,6 +152,7 @@ public class IrisConverter {
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (i != -1) J.car(i);
|
||||
if (titleClaim != null) {
|
||||
titleClaim.release();
|
||||
@@ -157,6 +161,7 @@ public class IrisConverter {
|
||||
barClaim.release();
|
||||
BukkitPlatform.hudLanes().hide(sender.player(), "iris:job");
|
||||
}
|
||||
}
|
||||
try {
|
||||
object.shrinkwrap();
|
||||
object.write(new File(folder, schem.getName().replace(".schem", ".iob")));
|
||||
|
||||
@@ -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,8 +55,11 @@ 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 (bypassMantleStages) {
|
||||
AtomicInteger bypassCounter = worldMaintenanceMantleBypassDepth.get(worldName);
|
||||
if (bypassCounter != null) {
|
||||
bypassDepth = bypassCounter.decrementAndGet();
|
||||
if (bypassDepth <= 0) {
|
||||
@@ -61,6 +67,7 @@ public final class WorldMaintenance {
|
||||
bypassDepth = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (IrisSettings.get().getGeneral().isDebug()) {
|
||||
IrisLogging.info("World maintenance exit: " + worldName + " reason=" + reason + " depth=" + depth + " bypassMantleDepth=" + bypassDepth);
|
||||
@@ -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);
|
||||
// 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,12 +149,16 @@ final class EngineShutdownSequence {
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
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);
|
||||
|
||||
@@ -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,13 +389,22 @@ 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()) {
|
||||
// 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());
|
||||
} else {
|
||||
IrisLogging.debug("Read Tectonic Plate " + C.DARK_GREEN + name + C.RED + " in " + Form.duration(stopwatch.getMilliseconds(), 2));
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user