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:
+70
-7
@@ -55,6 +55,7 @@ import it.unimi.dsi.fastutil.objects.Object2IntMap;
|
||||
import it.unimi.dsi.fastutil.shorts.ShortList;
|
||||
import net.bytebuddy.ByteBuddy;
|
||||
import net.bytebuddy.agent.builder.AgentBuilder;
|
||||
import net.bytebuddy.agent.builder.ResettableClassFileTransformer;
|
||||
import net.bytebuddy.asm.Advice;
|
||||
import net.bytebuddy.matcher.ElementMatchers;
|
||||
import net.minecraft.core.BlockPos;
|
||||
@@ -172,6 +173,7 @@ public class NMSBinding implements INMSBinding {
|
||||
private final BlockData AIR = Material.AIR.createBlockData();
|
||||
private final AtomicCache<MCAIdMap<net.minecraft.world.level.biome.Biome>> biomeMapCache = new AtomicCache<>();
|
||||
private final AtomicBoolean injected = new AtomicBoolean();
|
||||
private volatile ResettableClassFileTransformer serverLevelTransformer;
|
||||
private final AtomicCache<MCAIdMapper<BlockState>> registryCache = new AtomicCache<>();
|
||||
private final AtomicCache<MCAPalette<BlockState>> globalCache = new AtomicCache<>();
|
||||
private final AtomicCache<RegistryAccess> registryAccess = new AtomicCache<>();
|
||||
@@ -1523,7 +1525,7 @@ public class NMSBinding implements INMSBinding {
|
||||
}
|
||||
try {
|
||||
IrisLogging.info("Injecting Bukkit");
|
||||
new AgentBuilder.Default()
|
||||
serverLevelTransformer = new AgentBuilder.Default()
|
||||
.disableClassFormatChanges()
|
||||
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
|
||||
.type(ElementMatchers.is(ServerLevel.class))
|
||||
@@ -1546,6 +1548,17 @@ public class NMSBinding implements INMSBinding {
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.error(C.RED + "Failed to inject Bukkit");
|
||||
e.printStackTrace();
|
||||
// The ServerLevel transformer may already be installed when the ChunkAccess
|
||||
// redefine throws; remove it or a retry would stack a second one and orphan
|
||||
// this one permanently.
|
||||
ResettableClassFileTransformer partial = serverLevelTransformer;
|
||||
serverLevelTransformer = null;
|
||||
if (partial != null) {
|
||||
try {
|
||||
partial.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1562,6 +1575,24 @@ public class NMSBinding implements INMSBinding {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void uninjectBukkit() {
|
||||
synchronized (injected) {
|
||||
ResettableClassFileTransformer transformer = serverLevelTransformer;
|
||||
serverLevelTransformer = null;
|
||||
injected.set(false);
|
||||
if (transformer == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
transformer.reset(Agent.getInstrumentation(), AgentBuilder.RedefinitionStrategy.RETRANSFORMATION);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.error(C.RED + "Failed to remove ServerLevel injection");
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public KMap<Material, List<BlockProperty>> getBlockProperties() {
|
||||
KMap<Material, List<BlockProperty>> states = new KMap<>();
|
||||
@@ -1624,24 +1655,56 @@ public class NMSBinding implements INMSBinding {
|
||||
if (dimensionKey == null)
|
||||
return;
|
||||
|
||||
// This advice is inlined into every ServerLevel construction on the server. Until a
|
||||
// world is proven Iris-owned, every failure must fail OPEN (keep the vanilla stem):
|
||||
// Iris being unloaded or half-loaded must never break other plugins' world creation.
|
||||
String levelId;
|
||||
ClassLoader pluginClassLoader;
|
||||
Class<?> generatorType;
|
||||
Class<?> stagingType;
|
||||
try {
|
||||
String levelId = dimensionKey.identifier().getPath();
|
||||
levelId = dimensionKey.identifier().getPath();
|
||||
if (levelId == null || levelId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ClassLoader pluginClassLoader = Bukkit.getPluginManager().getPlugin("Iris").getClass().getClassLoader();
|
||||
Class<?> generatorType = Class.forName("art.arcane.iris.engine.platform.PlatformChunkGenerator", true, pluginClassLoader);
|
||||
org.bukkit.plugin.Plugin irisPlugin = Bukkit.getPluginManager().getPlugin("Iris");
|
||||
if (irisPlugin == null) {
|
||||
return;
|
||||
}
|
||||
pluginClassLoader = irisPlugin.getClass().getClassLoader();
|
||||
generatorType = Class.forName("art.arcane.iris.engine.platform.PlatformChunkGenerator", true, pluginClassLoader);
|
||||
stagingType = Class.forName("art.arcane.iris.core.lifecycle.WorldLifecycleStaging", true, pluginClassLoader);
|
||||
} catch (Throwable ignored) {
|
||||
// Iris absent or half-loaded: fail OPEN for a world we cannot prove ours.
|
||||
return;
|
||||
}
|
||||
|
||||
// From here Iris is present and its classes resolve; a failure resolving the
|
||||
// staged generator must fail LOUD — silently handing a possibly-staged Iris world
|
||||
// the vanilla stem corrupts generation.
|
||||
ChunkGenerator gen = null;
|
||||
try {
|
||||
Object generator = generatorType.isInstance(constructorGenerator) ? constructorGenerator : null;
|
||||
if (generator == null) {
|
||||
generator = Class.forName("art.arcane.iris.core.lifecycle.WorldLifecycleStaging", true, pluginClassLoader)
|
||||
generator = stagingType
|
||||
.getDeclaredMethod("consumeStemGenerator", String.class)
|
||||
.invoke(null, levelId);
|
||||
}
|
||||
if (!(generator instanceof ChunkGenerator gen) || !gen.getClass().getPackageName().startsWith("art.arcane.iris")) {
|
||||
return;
|
||||
if (generator instanceof ChunkGenerator owned && owned.getClass().getPackageName().startsWith("art.arcane.iris")) {
|
||||
gen = owned;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
throw new RuntimeException("Iris failed to resolve the staged world generator",
|
||||
e instanceof InvocationTargetException ex ? ex.getCause() : e);
|
||||
}
|
||||
if (gen == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Past the ownership gate the world is Iris-owned; silently handing back the
|
||||
// vanilla stem would corrupt generation, so failures from here rethrow.
|
||||
try {
|
||||
Object bindings = Class.forName("art.arcane.iris.core.nms.INMS", true, pluginClassLoader)
|
||||
.getDeclaredMethod("get")
|
||||
.invoke(null);
|
||||
|
||||
@@ -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));
|
||||
|
||||
+47
-44
@@ -328,35 +328,50 @@ public class CommandStudio implements DirectorExecutor {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD));
|
||||
return;
|
||||
}
|
||||
if (radius <= 0 || radius > 2048) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD));
|
||||
sender().sendMessage("Radius must be between 1 and 2048 chunks.");
|
||||
return;
|
||||
}
|
||||
var sender = sender();
|
||||
var player = player();
|
||||
Thread.ofVirtual()
|
||||
.start(() -> {
|
||||
int d = radius * 2;
|
||||
KMap<String, AtomicInteger> data = new KMap<>();
|
||||
// Everything acquired below is released in the finally: a throw mid-scan
|
||||
// previously leaked the whole sampler ForkJoinPool, the self-rescheduling
|
||||
// progress task and both HUD claims for the rest of the server's uptime.
|
||||
MultiBurst multiBurst = null;
|
||||
HudSlotClaim titleClaim = null;
|
||||
HudSlotClaim barClaim = null;
|
||||
int c = -1;
|
||||
try {
|
||||
engine.getDimension().getRegions().forEach(key -> data.put(key, new AtomicInteger(0)));
|
||||
var multiBurst = new MultiBurst("Region Sampler");
|
||||
var executor = multiBurst.burst(radius * radius);
|
||||
multiBurst = new MultiBurst("Region Sampler");
|
||||
var executor = multiBurst.burst(Math.min(radius * radius, 1 << 20));
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_GENERATING_DATA));
|
||||
var loc = player.getLocation();
|
||||
int totalTasks = d * d;
|
||||
AtomicInteger completedTasks = new AtomicInteger(0);
|
||||
HudSlotClaim titleClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.TITLE)));
|
||||
HudSlotClaim barClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.ACTION_BAR, HudSurface.BOSS_BAR)));
|
||||
titleClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.TITLE)));
|
||||
barClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.ACTION_BAR, HudSurface.BOSS_BAR)));
|
||||
HudSlotClaim finalTitleClaim = titleClaim;
|
||||
HudSlotClaim finalBarClaim = barClaim;
|
||||
AtomicLong lastResolveMs = new AtomicLong(0L);
|
||||
int c = J.ar(() -> {
|
||||
c = J.ar(() -> {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastResolveMs.get() >= 250L) {
|
||||
lastResolveMs.set(now);
|
||||
titleClaim.resolve();
|
||||
barClaim.resolve();
|
||||
finalTitleClaim.resolve();
|
||||
finalBarClaim.resolve();
|
||||
}
|
||||
double jobProgress = (double) completedTasks.get() / totalTasks;
|
||||
HudSurface barSurface = barClaim.granted();
|
||||
HudSurface barSurface = finalBarClaim.granted();
|
||||
sender.sendProgress(
|
||||
jobProgress,
|
||||
IrisLanguage.text(RuntimeUiMessages.FINDING_REGIONS),
|
||||
titleClaim.granted(),
|
||||
finalTitleClaim.granted(),
|
||||
barSurface
|
||||
);
|
||||
if (barSurface == HudSurface.BOSS_BAR) {
|
||||
@@ -372,15 +387,28 @@ public class CommandStudio implements DirectorExecutor {
|
||||
completedTasks.incrementAndGet();
|
||||
})).setOffset(loc.getBlockX(), loc.getBlockZ()).drain();
|
||||
executor.complete();
|
||||
multiBurst.close();
|
||||
J.car(c);
|
||||
titleClaim.release();
|
||||
barClaim.release();
|
||||
BukkitPlatform.hudLanes().hide(player, "iris:job");
|
||||
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_DONE));
|
||||
var loader = engine.getData().getRegionLoader();
|
||||
data.forEach((k, v) -> sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_MESSAGE, MessageArgument.untrusted("k", k), MessageArgument.untrusted("value", loader.load(k).getRarity()), MessageArgument.untrusted("value2", Form.f((double) v.get() / totalTasks * 100, 2)))));
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError(e);
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_ONLY_WORKS_IRIS_WORLD));
|
||||
} finally {
|
||||
if (c != -1) {
|
||||
J.car(c);
|
||||
}
|
||||
if (titleClaim != null) {
|
||||
titleClaim.release();
|
||||
}
|
||||
if (barClaim != null) {
|
||||
barClaim.release();
|
||||
BukkitPlatform.hudLanes().hide(player, "iris:job");
|
||||
}
|
||||
if (multiBurst != null) {
|
||||
multiBurst.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -437,7 +465,6 @@ public class CommandStudio implements DirectorExecutor {
|
||||
KMap<InterpolationMethod, Double> interpolatorTimings = new KMap<>();
|
||||
KMap<String, Double> generatorTimings = new KMap<>();
|
||||
KMap<String, Double> biomeTimings = new KMap<>();
|
||||
KMap<String, Double> regionTimings = new KMap<>();
|
||||
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_CALCULATING_PERFORMANCE_METRICS_NOISE_GENERATORS));
|
||||
|
||||
@@ -561,23 +588,6 @@ public class CommandStudio implements DirectorExecutor {
|
||||
|
||||
fileText.add("");
|
||||
|
||||
for (String i : data.getRegionLoader().getPossibleKeys()) {
|
||||
IrisRegion b = data.getRegionLoader().load(i);
|
||||
double score = 0;
|
||||
|
||||
score += styleTimings.get(b.getLakeStyle().getStyle());
|
||||
score += styleTimings.get(b.getRiverStyle().getStyle());
|
||||
regionTimings.put(i, score);
|
||||
}
|
||||
|
||||
fileText.add("Project Region Performance Impacts: ");
|
||||
|
||||
for (String i : regionTimings.sortKNumber()) {
|
||||
fileText.add(i + ": " + regionTimings.get(i));
|
||||
}
|
||||
|
||||
fileText.add("");
|
||||
|
||||
double m = 0;
|
||||
for (double i : biomeTimings.v()) {
|
||||
m += i;
|
||||
@@ -589,12 +599,6 @@ public class CommandStudio implements DirectorExecutor {
|
||||
}
|
||||
mm /= generatorTimings.size();
|
||||
m += mm;
|
||||
double mmm = 0;
|
||||
for (double i : regionTimings.v()) {
|
||||
mmm += i;
|
||||
}
|
||||
mmm /= regionTimings.size();
|
||||
m += mmm;
|
||||
|
||||
fileText.add("Average Score: " + m);
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_SCORE, MessageArgument.untrusted("value", Form.duration(m, 0))));
|
||||
@@ -763,9 +767,8 @@ public class CommandStudio implements DirectorExecutor {
|
||||
}
|
||||
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_CAPTURING_IGENDATA_FROM_NEARBY_CHUNKS, MessageArgument.untrusted("value", chunks.size())));
|
||||
try {
|
||||
File ff = Iris.instance.getDataFile("reports/" + M.ms() + ".txt");
|
||||
PrintWriter pw = new PrintWriter(ff);
|
||||
File ff = Iris.instance.getDataFile("reports/" + M.ms() + ".txt");
|
||||
try (PrintWriter pw = new PrintWriter(ff)) {
|
||||
pw.println("=== Iris Chunk Report ===");
|
||||
pw.println("== General Info ==");
|
||||
pw.println("Iris Version: " + Iris.instance.getDescription().getVersion());
|
||||
@@ -820,7 +823,8 @@ public class CommandStudio implements DirectorExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
regions = Objects.requireNonNull(new File(world.getWorldFolder().getPath() + "/region").list()).length;
|
||||
String[] regionFiles = new File(world.getWorldFolder(), "region").list();
|
||||
regions = regionFiles == null ? 0 : regionFiles.length;
|
||||
|
||||
pw.println();
|
||||
pw.println("== World Info ==");
|
||||
@@ -854,10 +858,9 @@ public class CommandStudio implements DirectorExecutor {
|
||||
}
|
||||
|
||||
pw.println();
|
||||
pw.close();
|
||||
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_REPORTED, MessageArgument.untrusted("value", ff.getPath())));
|
||||
} catch (FileNotFoundException e) {
|
||||
} catch (Throwable e) {
|
||||
e.printStackTrace();
|
||||
Iris.reportError(e);
|
||||
}
|
||||
|
||||
+57
-15
@@ -26,7 +26,10 @@ import art.arcane.iris.core.edit.BlockSignal;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionException;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.platform.EngineBukkitOps;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.util.common.director.DirectorExecutor;
|
||||
@@ -36,8 +39,10 @@ import art.arcane.volmlib.util.director.DirectorOrigin;
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.matter.MatterMarker;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.FluidCollisionMode;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
@@ -47,7 +52,6 @@ import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import art.arcane.iris.core.localization.IrisLanguage;
|
||||
import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
|
||||
@@ -172,35 +176,73 @@ public class CommandWhat implements DirectorExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
// Matches ModdedWhatCommands.MAX_MARKERS so both loaders truncate identically.
|
||||
private static final int MAX_MARKER_HITS = 8_192;
|
||||
private static final int MARKER_SIGNAL_BATCH = 128;
|
||||
|
||||
@Director(description = "Show markers in chunk", descriptionKey = "iris.director.commandwhat.director.show_markers_chunk", origin = DirectorOrigin.PLAYER)
|
||||
public void markers(@Param(description = "Marker name such as cave_floor or cave_ceiling", descriptionKey = "iris.director.commandwhat.param.marker_name_such_as_cave_floor_cave_ceiling") String marker) {
|
||||
VolmitSender commandSender = sender();
|
||||
Player player = player();
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Chunk lookup plus the block signals both need the thread owning the player's chunk.
|
||||
// Only the chunk coordinates need the owning thread; the 81-chunk mantle sweep loads
|
||||
// tectonic plates from disk and must never run on the tick thread (the modded adapter
|
||||
// already scans async, leased and capped — mirror it).
|
||||
onPlayerThread(player, () -> {
|
||||
World world = player.getWorld();
|
||||
Chunk c = player.getLocation().getChunk();
|
||||
int chunkX = c.getX();
|
||||
int chunkZ = c.getZ();
|
||||
|
||||
if (IrisToolbelt.isIrisWorld(c.getWorld())) {
|
||||
AtomicInteger v = new AtomicInteger(0);
|
||||
if (!IrisToolbelt.isIrisWorld(world)) {
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IRIS_WORLDS_ONLY_2));
|
||||
return;
|
||||
}
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
|
||||
for (int xxx = c.getX() - 4; xxx <= c.getX() + 4; xxx++) {
|
||||
for (int zzz = c.getZ() - 4; zzz <= c.getZ() + 4; zzz++) {
|
||||
IrisToolbelt.access(c.getWorld()).getEngine().getMantle().findMarkers(xxx, zzz, new MatterMarker(marker))
|
||||
.convert((i) -> BukkitPlatform.toLocation(i, c.getWorld())).forEach((i) -> {
|
||||
BlockSignal.of(i.getWorld(), i.getBlockX(), i.getBlockY(), i.getBlockZ(), 100);
|
||||
v.incrementAndGet();
|
||||
});
|
||||
J.a(() -> {
|
||||
KList<Location> hits = new KList<>();
|
||||
MatterMarker matterMarker = new MatterMarker(marker);
|
||||
try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_what_markers");
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
scan:
|
||||
for (int xxx = chunkX - 4; xxx <= chunkX + 4; xxx++) {
|
||||
for (int zzz = chunkZ - 4; zzz <= chunkZ + 4; zzz++) {
|
||||
for (Location i : engine.getMantle().findMarkers(xxx, zzz, matterMarker)
|
||||
.convert((p) -> BukkitPlatform.toLocation(p, world))) {
|
||||
hits.add(i);
|
||||
if (hits.size() >= MAX_MARKER_HITS) {
|
||||
break scan;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (GenerationSessionException e) {
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IRIS_WORLDS_ONLY_2));
|
||||
return;
|
||||
}
|
||||
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_FOUND_NEARBY_MARKERS, MessageArgument.untrusted("value", v.get()), MessageArgument.untrusted("marker", marker)));
|
||||
} else {
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_IRIS_WORLDS_ONLY_2));
|
||||
}
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_WHAT_FOUND_NEARBY_MARKERS, MessageArgument.untrusted("value", hits.size()), MessageArgument.untrusted("marker", marker)));
|
||||
emitMarkerSignals(player, engine, hits, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void emitMarkerSignals(Player player, Engine engine, KList<Location> hits, int from) {
|
||||
if (from >= hits.size() || !player.isOnline() || engine.isClosed() || engine.isClosing()) {
|
||||
return;
|
||||
}
|
||||
int to = Math.min(from + MARKER_SIGNAL_BATCH, hits.size());
|
||||
for (int i = from; i < to; i++) {
|
||||
Location at = hits.get(i);
|
||||
BlockSignal.of(at.getWorld(), at.getBlockX(), at.getBlockY(), at.getBlockZ(), 100);
|
||||
}
|
||||
J.s(() -> emitMarkerSignals(player, engine, hits, to), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the body on the thread owning the player, reporting when the hop cannot be scheduled.
|
||||
*/
|
||||
|
||||
+19
-5
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.IrisStartupValidation;
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService;
|
||||
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
|
||||
import art.arcane.iris.core.nms.DatapackStructureScopeResult;
|
||||
@@ -29,13 +30,26 @@ public final class DatapackStructureScopeSVC implements IrisService {
|
||||
scopeIndex = DatapackStructureScopeIndex.create(
|
||||
DatapackIngestService.installedStructureScopeResources());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(
|
||||
"Iris could not establish ownership for installed datapack structure sets", e);
|
||||
// A missing manifest entry is the condition validateOnStartup already classified as
|
||||
// non-fatal; degrade to an empty scope and lock world creation instead of aborting
|
||||
// the whole plugin bootstrap (which would skip listeners and journal reconciliation).
|
||||
scopeIndex = DatapackStructureScopeIndex.create(null);
|
||||
IrisLogging.error("Could not establish ownership for installed datapack structure sets; "
|
||||
+ "datapack structure scoping is disabled until the manifest is repaired: " + e.getMessage());
|
||||
IrisStartupValidation.markDatapacksInvalid(
|
||||
"Iris could not establish ownership for installed datapack structure sets: " + e.getMessage());
|
||||
}
|
||||
for (World world : Bukkit.getWorlds()) {
|
||||
IrisImportedStructureControl importedStructures = importedStructures(world);
|
||||
if (!scopeIndex.isEmpty() || importedStructures != null && importedStructures.hasFrequencyOverrides()) {
|
||||
applyScope(world, importedStructures);
|
||||
try {
|
||||
IrisImportedStructureControl importedStructures = importedStructures(world);
|
||||
if (!scopeIndex.isEmpty() || importedStructures != null && importedStructures.hasFrequencyOverrides()) {
|
||||
applyScope(world, importedStructures);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
// Per-world containment: one world's scoping failure must not abort the
|
||||
// service enable (and with it the whole bootstrap's containment contract).
|
||||
IrisLogging.error("Could not scope datapack structures for world '" + world.getName() + "'.");
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ import org.bukkit.event.world.WorldUnloadEvent;
|
||||
public class EditSVC implements IrisService, BlockEditAccess<World, BlockData, Biome> {
|
||||
private KMap<World, BlockEditor> editors;
|
||||
private int updateTaskId = -1;
|
||||
public static boolean deletingWorld = false;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -84,7 +83,7 @@ public class EditSVC implements IrisService, BlockEditAccess<World, BlockData, B
|
||||
if (editors == null) {
|
||||
return;
|
||||
}
|
||||
if (editors.containsKey(e.getWorld()) && !deletingWorld) {
|
||||
if (editors.containsKey(e.getWorld())) {
|
||||
editors.remove(e.getWorld()).close();
|
||||
}
|
||||
}
|
||||
|
||||
+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) {
|
||||
|
||||
+17
-1
@@ -47,6 +47,7 @@ import art.arcane.iris.modded.service.ModdedTreeFellerService;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.util.common.parallel.MultiBurst;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
@@ -99,6 +100,11 @@ public final class ModdedEngineBootstrap {
|
||||
captureInitialSpawn(server);
|
||||
currentServer = server;
|
||||
bind();
|
||||
// Pair of the stop() burst-pools stage. Load-bearing on integrated servers: once
|
||||
// closed, MultiBurst falls back to a same-thread executor, so a second world load
|
||||
// without reopen() would silently run every burst inline.
|
||||
MultiBurst.burst.reopen();
|
||||
MultiBurst.ioBurst.reopen();
|
||||
ModdedScheduler scheduler = schedulerOrNull();
|
||||
if (scheduler != null) {
|
||||
scheduler.reset();
|
||||
@@ -170,6 +176,7 @@ public final class ModdedEngineBootstrap {
|
||||
failure = runStopStage(failure, "wand service", ModdedWandService::clearAll);
|
||||
failure = runStopStage(failure, "block break handler", ModdedBlockBreakHandler::clear);
|
||||
failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear);
|
||||
failure = runStopStage(failure, "gui host", ModdedGuiHost::clear);
|
||||
failure = runStopStage(failure, "services", () -> services().disableAll());
|
||||
failure = runStopStage(failure, "world engines", ModdedWorldEngines::shutdown);
|
||||
failure = runStopStage(failure, "primary world router", ModdedPrimaryWorldRouter::clear);
|
||||
@@ -183,6 +190,10 @@ public final class ModdedEngineBootstrap {
|
||||
failure = runStopStage(failure, "level snapshot", ModdedServerLevels::forget);
|
||||
}
|
||||
failure = runStopStage(failure, "generation pool", IrisModdedChunkGenerator::shutdownGenPool);
|
||||
failure = runStopStage(failure, "burst pools", () -> {
|
||||
MultiBurst.burst.close();
|
||||
MultiBurst.ioBurst.close();
|
||||
});
|
||||
failure = runStopStage(failure, "sentry", ModdedSentry::flush);
|
||||
failure = runStopStage(failure, "startup state", ModdedStartup::reset);
|
||||
failure = runStopStage(failure, "server state", () -> {
|
||||
@@ -386,7 +397,12 @@ public final class ModdedEngineBootstrap {
|
||||
ModdedCustomContentRegistry.Discovery customContentDiscovery =
|
||||
ModdedCustomContentRegistry.discover();
|
||||
rollback.add(customContentDiscovery::rollback);
|
||||
ModdedIrisSplash.print(boundLoader);
|
||||
try {
|
||||
ModdedIrisSplash.print(boundLoader);
|
||||
} catch (Throwable splashFailure) {
|
||||
// A cosmetic banner must never roll back the platform bind.
|
||||
LOGGER.warn("Iris splash could not be printed", splashFailure);
|
||||
}
|
||||
createdServices.enableAll();
|
||||
runtime = new BoundRuntime(created, createdServices);
|
||||
rollback.clear();
|
||||
|
||||
+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
|
||||
|
||||
+19
-9
@@ -18,6 +18,7 @@
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
@@ -26,6 +27,7 @@ import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionRuntimeContract;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.modded.command.ModdedGuiHost;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.dimension.DimensionType;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
@@ -76,6 +78,8 @@ public final class ModdedWorldEngines {
|
||||
if (removed[0] == null) {
|
||||
return;
|
||||
}
|
||||
// The GUI host holds strong Engine/ServerLevel references with no other remove path.
|
||||
ModdedGuiHost.unbind(removed[0]);
|
||||
LOGGER.info("Iris engine evicted for {}", level.dimension().identifier());
|
||||
}
|
||||
|
||||
@@ -89,6 +93,7 @@ public final class ModdedWorldEngines {
|
||||
ENGINES.compute(activeLevel, (ServerLevel ignored, Engine current) -> {
|
||||
if (current != null && current != activeReplacement) {
|
||||
close(current);
|
||||
ModdedGuiHost.unbind(current);
|
||||
}
|
||||
return activeReplacement;
|
||||
});
|
||||
@@ -96,6 +101,7 @@ public final class ModdedWorldEngines {
|
||||
|
||||
static void closeUnregistered(Engine engine) {
|
||||
close(engine);
|
||||
ModdedGuiHost.unbind(engine);
|
||||
}
|
||||
|
||||
private static Engine create(ServerLevel level, String pack, String dimensionKey, long seedOverride) {
|
||||
@@ -167,11 +173,7 @@ public final class ModdedWorldEngines {
|
||||
}
|
||||
|
||||
public static File packFolder(String pack) {
|
||||
return ModdedEngineBootstrap.loader().configDir()
|
||||
.resolve("irisworldgen")
|
||||
.resolve("packs")
|
||||
.resolve(pack)
|
||||
.toFile();
|
||||
return IrisPlatforms.get().packsFolderNoCreate(pack);
|
||||
}
|
||||
|
||||
static File resolvePack(String pack, String dimensionKey) {
|
||||
@@ -200,10 +202,18 @@ public final class ModdedWorldEngines {
|
||||
ServerLevel level = entry.getKey();
|
||||
Engine engine = entry.getValue();
|
||||
try {
|
||||
close(engine);
|
||||
if (!ENGINES.remove(level, engine) && ENGINES.containsKey(level)) {
|
||||
throw new IllegalStateException("Iris engine mapping changed during shutdown for "
|
||||
+ level.dimension().identifier());
|
||||
// Latch the generator's unloading flag BEFORE closing (unbindEngine sets it,
|
||||
// then evicts): chunk-system drain work running after this stage would
|
||||
// otherwise see a closed engine and silently rebuild a fresh engine + Mantle
|
||||
// that no teardown stage ever closes, writing plates after the final save.
|
||||
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator) {
|
||||
generator.unbindEngine(level);
|
||||
} else {
|
||||
close(engine);
|
||||
if (!ENGINES.remove(level, engine) && ENGINES.containsKey(level)) {
|
||||
throw new IllegalStateException("Iris engine mapping changed during shutdown for "
|
||||
+ level.dimension().identifier());
|
||||
}
|
||||
}
|
||||
LOGGER.info("Iris engine closed for {}", level.dimension().identifier());
|
||||
} catch (Throwable e) {
|
||||
|
||||
+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");
|
||||
|
||||
+27
-22
@@ -291,30 +291,35 @@ public final class ModdedObjectCommands {
|
||||
}
|
||||
int[] tilesSkipped = {0};
|
||||
int[] tilesSaved = {0};
|
||||
// capture() must stay on the server thread (getBlockState/getBlockEntity are not
|
||||
// async-safe), but the disk write of a local, unshared object is not tick work.
|
||||
IrisObject object = capture(level, min, max, w, h, d, tilesSkipped, tilesSaved);
|
||||
File parent = file.getParentFile();
|
||||
if (parent != null) {
|
||||
parent.mkdirs();
|
||||
}
|
||||
try {
|
||||
object.write(file);
|
||||
} catch (IOException e) {
|
||||
LOGGER.error("Iris object save failed for {}", file.getAbsolutePath(), e);
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FAILED_SAVE_OBJECT, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
|
||||
return 0;
|
||||
}
|
||||
StringBuilder tileNote = new StringBuilder();
|
||||
if (tilesSaved[0] > 0) {
|
||||
tileNote.append(" (").append(tilesSaved[0]).append(" tile entity state(s) captured");
|
||||
if (tilesSkipped[0] > 0) {
|
||||
tileNote.append(", ").append(tilesSkipped[0]).append(" failed");
|
||||
MinecraftServer server = source.getServer();
|
||||
J.a(() -> {
|
||||
File parent = file.getParentFile();
|
||||
if (parent != null) {
|
||||
parent.mkdirs();
|
||||
}
|
||||
tileNote.append(")");
|
||||
} else if (tilesSkipped[0] > 0) {
|
||||
tileNote.append(" (").append(tilesSkipped[0]).append(" tile state(s) could not be captured)");
|
||||
}
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_SAVED_OBJECTS_IOB_X_X_BLOCK_S, MessageArgument.untrusted("value", engine.getData().getDataFolder().getName()), MessageArgument.untrusted("name", name), MessageArgument.untrusted("w", w), MessageArgument.untrusted("h", h), MessageArgument.untrusted("d", d), MessageArgument.untrusted("value2", object.getBlocks().size()), MessageArgument.untrusted("tileNote", tileNote)));
|
||||
LOGGER.info("Iris object save: {} {}x{}x{} blocks={} tilesSaved={} tilesSkipped={} -> {}", name, w, h, d, object.getBlocks().size(), tilesSaved[0], tilesSkipped[0], file.getAbsolutePath());
|
||||
try {
|
||||
object.write(file);
|
||||
} catch (IOException e) {
|
||||
LOGGER.error("Iris object save failed for {}", file.getAbsolutePath(), e);
|
||||
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_FAILED_SAVE_OBJECT, MessageArgument.untrusted("value", String.valueOf(e.getMessage())))));
|
||||
return;
|
||||
}
|
||||
StringBuilder tileNote = new StringBuilder();
|
||||
if (tilesSaved[0] > 0) {
|
||||
tileNote.append(" (").append(tilesSaved[0]).append(" tile entity state(s) captured");
|
||||
if (tilesSkipped[0] > 0) {
|
||||
tileNote.append(", ").append(tilesSkipped[0]).append(" failed");
|
||||
}
|
||||
tileNote.append(")");
|
||||
} else if (tilesSkipped[0] > 0) {
|
||||
tileNote.append(" (").append(tilesSkipped[0]).append(" tile state(s) could not be captured)");
|
||||
}
|
||||
server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_OBJECT_COMMANDS_SAVED_OBJECTS_IOB_X_X_BLOCK_S, MessageArgument.untrusted("value", engine.getData().getDataFolder().getName()), MessageArgument.untrusted("name", name), MessageArgument.untrusted("w", w), MessageArgument.untrusted("h", h), MessageArgument.untrusted("d", d), MessageArgument.untrusted("value2", object.getBlocks().size()), MessageArgument.untrusted("tileNote", tileNote))));
|
||||
LOGGER.info("Iris object save: {} {}x{}x{} blocks={} tilesSaved={} tilesSkipped={} -> {}", name, w, h, d, object.getBlocks().size(), tilesSaved[0], tilesSkipped[0], file.getAbsolutePath());
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
+32
-2
@@ -19,6 +19,7 @@
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
@@ -88,10 +89,22 @@ public final class ModdedObjectUndo {
|
||||
if (entry == null) {
|
||||
break;
|
||||
}
|
||||
// Identity check, not just null: a studio closed and reopened under the same
|
||||
// dimension id must never have blocks replayed into the dead ServerLevel.
|
||||
MinecraftServer server = entry.level().getServer();
|
||||
if (server == null || server.getLevel(entry.level().dimension()) != entry.level()) {
|
||||
LOGGER.warn("Iris object undo: skipped a stale entry for removed dimension {}",
|
||||
entry.level().dimension().identifier());
|
||||
continue;
|
||||
}
|
||||
int writes = 0;
|
||||
for (Map.Entry<BlockPos, BlockState> block : entry.blocks().entrySet()) {
|
||||
entry.level().setBlock(block.getKey(), block.getValue(), Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE);
|
||||
writes++;
|
||||
try {
|
||||
entry.level().setBlock(block.getKey(), block.getValue(), Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE);
|
||||
writes++;
|
||||
} catch (Throwable e) {
|
||||
LOGGER.error("Iris object undo: failed to revert a block at {}", block.getKey(), e);
|
||||
}
|
||||
}
|
||||
LOGGER.info("Iris object undo: reverted {} block(s) in {}", writes, entry.level().dimension().identifier());
|
||||
reverted++;
|
||||
@@ -99,6 +112,23 @@ public final class ModdedObjectUndo {
|
||||
return reverted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops every entry recorded against the given level. Called on dimension removal so a
|
||||
* closed studio releases its block snapshots and the ServerLevel reference.
|
||||
*/
|
||||
public static void forget(ServerLevel level) {
|
||||
if (level == null) {
|
||||
return;
|
||||
}
|
||||
UNDOS.entrySet().removeIf((Map.Entry<UUID, Deque<Entry>> ownerEntry) -> {
|
||||
Deque<Entry> queue = ownerEntry.getValue();
|
||||
synchronized (queue) {
|
||||
queue.removeIf((Entry entry) -> entry.level() == level);
|
||||
return queue.isEmpty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void clearAll() {
|
||||
UNDOS.clear();
|
||||
}
|
||||
|
||||
+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) {
|
||||
|
||||
+47
-13
@@ -23,6 +23,7 @@ import art.arcane.iris.core.gui.GuiHost;
|
||||
import art.arcane.iris.core.gui.NoiseExplorerGUI;
|
||||
import art.arcane.iris.core.gui.VisionGUI;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.pack.PackDownloader;
|
||||
import art.arcane.iris.core.pack.StructurePackageClosure;
|
||||
import art.arcane.iris.core.project.IrisProjectCopier;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
@@ -31,7 +32,9 @@ import art.arcane.iris.engine.object.IrisBiomeGeneratorLink;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisEntitySpawn;
|
||||
import art.arcane.iris.engine.object.IrisGenerator;
|
||||
import art.arcane.iris.engine.object.IrisObjectPlacement;
|
||||
import art.arcane.iris.core.pack.PackExportClosure;
|
||||
import art.arcane.iris.engine.object.IrisEntity;
|
||||
import art.arcane.iris.engine.object.IrisMarker;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisSpawner;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
@@ -383,7 +386,7 @@ public final class ModdedStudioCommands {
|
||||
File packFolder = new File(ModdedPackCommands.packsRoot(), pack);
|
||||
if (!new File(packFolder, "dimensions/" + pack + ".json").isFile()) {
|
||||
server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_MISSING_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))));
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, "master", false, true,
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, PackDownloader.DEFAULT_BRANCH, false, true,
|
||||
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
|
||||
if (!installed || !new File(packFolder, "dimensions/" + pack + ".json").isFile()) {
|
||||
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_COULD_NOT_BE_DOWNLOADED_CHECK_NAME_TRY_IRIS_DOWNLOAD, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))));
|
||||
@@ -467,7 +470,9 @@ public final class ModdedStudioCommands {
|
||||
ServerPlayer player = source.getPlayer();
|
||||
MinecraftServer server = source.getServer();
|
||||
UUID owner = player == null ? CONSOLE_OWNER : player.getUUID();
|
||||
String dimensionId = STUDIOS.remove(owner);
|
||||
// Commit the ownership drop only after removal succeeds: dropping it first orphaned a
|
||||
// still-registered studio that no command could ever remove again.
|
||||
String dimensionId = STUDIOS.get(owner);
|
||||
if (dimensionId == null) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN));
|
||||
return 0;
|
||||
@@ -479,6 +484,7 @@ public final class ModdedStudioCommands {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSE_FAILED, MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e))));
|
||||
return 0;
|
||||
}
|
||||
STUDIOS.remove(owner, dimensionId);
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSED_WAS_EVACUATED_UNLOADED_ITS_REGION_DATA_DELETED, MessageArgument.untrusted("dimensionId", dimensionId)));
|
||||
return 1;
|
||||
}
|
||||
@@ -588,7 +594,7 @@ public final class ModdedStudioCommands {
|
||||
File templateFolder = new File(packsRoot, template);
|
||||
if (!new File(templateFolder, "dimensions/" + template + ".json").isFile()) {
|
||||
server.execute(() -> IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_IS_NOT_INSTALLED_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template))));
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), template, "master", false, true,
|
||||
boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), template, PackDownloader.DEFAULT_BRANCH, false, true,
|
||||
(String line) -> server.execute(() -> IrisModdedCommands.ok(source, line)));
|
||||
if (!installed || !new File(templateFolder, "dimensions/" + template + ".json").isFile()) {
|
||||
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TEMPLATE_COULD_NOT_BE_DOWNLOADED_INSTALL_PACK_WITH_DIMENSIONS_JSON, MessageArgument.untrusted("template", template), MessageArgument.untrusted("template2", template))));
|
||||
@@ -655,6 +661,7 @@ public final class ModdedStudioCommands {
|
||||
LinkedHashSet<String> generatorKeys = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> lootKeys = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> objectKeys = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> markerKeys = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> structureKeys = new LinkedHashSet<>();
|
||||
|
||||
regionKeys.addAll(dimension.getRegions());
|
||||
@@ -675,6 +682,8 @@ public final class ModdedStudioCommands {
|
||||
lootKeys.addAll(region.getLoot().getTables());
|
||||
spawnerKeys.addAll(region.getEntitySpawners());
|
||||
collectStructureKeys(structureKeys, region.getStructures());
|
||||
objectKeys.addAll(PackExportClosure.collectObjectKeys(region.getObjects()));
|
||||
markerKeys.addAll(PackExportClosure.collectMarkerKeys(region.getObjects()));
|
||||
}
|
||||
for (String biomeKey : biomeKeys) {
|
||||
IrisBiome biome = dm.getBiomeLoader().load(biomeKey);
|
||||
@@ -685,9 +694,15 @@ public final class ModdedStudioCommands {
|
||||
lootKeys.addAll(biome.getLoot().getTables());
|
||||
spawnerKeys.addAll(biome.getEntitySpawners());
|
||||
collectStructureKeys(structureKeys, biome.getStructures());
|
||||
for (IrisObjectPlacement placement : biome.getObjects()) {
|
||||
objectKeys.addAll(placement.getPlace());
|
||||
objectKeys.addAll(PackExportClosure.collectObjectKeys(biome.getObjects()));
|
||||
markerKeys.addAll(PackExportClosure.collectMarkerKeys(biome.getObjects()));
|
||||
}
|
||||
for (String markerKey : markerKeys) {
|
||||
IrisMarker marker = dm.getMarkerLoader().load(markerKey);
|
||||
if (marker == null) {
|
||||
continue;
|
||||
}
|
||||
spawnerKeys.addAll(marker.getSpawners());
|
||||
}
|
||||
for (String spawnerKey : spawnerKeys) {
|
||||
IrisSpawner spawner = dm.getSpawnerLoader().load(spawnerKey);
|
||||
@@ -695,6 +710,14 @@ public final class ModdedStudioCommands {
|
||||
continue;
|
||||
}
|
||||
spawner.getSpawns().forEach((IrisEntitySpawn spawn) -> entityKeys.add(spawn.getEntity()));
|
||||
spawner.getInitialSpawns().forEach((IrisEntitySpawn spawn) -> entityKeys.add(spawn.getEntity()));
|
||||
}
|
||||
for (String entityKey : entityKeys) {
|
||||
IrisEntity entity = dm.getEntityLoader().load(entityKey);
|
||||
if (entity == null) {
|
||||
continue;
|
||||
}
|
||||
lootKeys.addAll(entity.getLoot().getTables());
|
||||
}
|
||||
|
||||
StringBuilder hashes = new StringBuilder();
|
||||
@@ -732,6 +755,12 @@ public final class ModdedStudioCommands {
|
||||
for (String key : lootKeys) {
|
||||
hashes.append(copyJson(folder, "loot", key, dm.getLootLoader().findFile(key)));
|
||||
}
|
||||
for (String key : spawnerKeys) {
|
||||
hashes.append(copyJson(folder, "spawners", key, dm.getSpawnerLoader().findFile(key)));
|
||||
}
|
||||
for (String key : markerKeys) {
|
||||
hashes.append(copyJson(folder, "markers", key, dm.getMarkerLoader().findFile(key)));
|
||||
}
|
||||
|
||||
JSONObject meta = new JSONObject();
|
||||
meta.put("hash", IO.hash(hashes.toString()));
|
||||
@@ -794,14 +823,19 @@ public final class ModdedStudioCommands {
|
||||
int totalTasks = diameter * diameter;
|
||||
KMap<String, AtomicInteger> counts = new KMap<>();
|
||||
engine.getDimension().getRegions().forEach((String key) -> counts.put(key, new AtomicInteger(0)));
|
||||
// finally-scoped: a throw mid-scan previously leaked the sampler's whole
|
||||
// ForkJoinPool (close() is the only thing that shuts it down).
|
||||
MultiBurst burst = new MultiBurst("Region Sampler");
|
||||
BurstExecutor executor = burst.burst(totalTasks);
|
||||
new Spiraler(diameter, diameter, (int x, int z) -> executor.queue(() -> {
|
||||
IrisRegion region = engine.getRegion((x << 4) + 8, (z << 4) + 8);
|
||||
counts.computeIfAbsent(region.getLoadKey(), (String key) -> new AtomicInteger(0)).incrementAndGet();
|
||||
})).setOffset(blockX >> 4, blockZ >> 4).drain();
|
||||
executor.complete();
|
||||
burst.close();
|
||||
try {
|
||||
BurstExecutor executor = burst.burst(totalTasks);
|
||||
new Spiraler(diameter, diameter, (int x, int z) -> executor.queue(() -> {
|
||||
IrisRegion region = engine.getRegion((x << 4) + 8, (z << 4) + 8);
|
||||
counts.computeIfAbsent(region.getLoadKey(), (String key) -> new AtomicInteger(0)).incrementAndGet();
|
||||
})).setOffset(blockX >> 4, blockZ >> 4).drain();
|
||||
executor.complete();
|
||||
} finally {
|
||||
burst.close();
|
||||
}
|
||||
server.execute(() -> counts.forEach((String key, AtomicInteger count) -> {
|
||||
IrisRegion region = engine.getData().getRegionLoader().load(key);
|
||||
String rarity = region == null ? "?" : String.valueOf(region.getRarity());
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user