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

This commit is contained in:
Brian Neumann-Fopiano
2026-08-13 01:01:01 -04:00
parent 13e02ecd2f
commit 14b6280668
215 changed files with 4604 additions and 2407 deletions
@@ -41,7 +41,7 @@ public class IrisSettings {
private IrisSettingsGUI gui = new IrisSettingsGUI();
private IrisSettingsAutoconfiguration autoConfiguration = new IrisSettingsAutoconfiguration();
private IrisSettingsGenerator generator = new IrisSettingsGenerator();
private IrisSettingsConcurrency concurrency = new IrisSettingsConcurrency();
private transient IrisSettingsConcurrency concurrency = new IrisSettingsConcurrency();
private IrisSettingsStudio studio = new IrisSettingsStudio();
private IrisSettingsPerformance performance = new IrisSettingsPerformance();
private IrisSettingsPregen pregen = new IrisSettingsPregen();
@@ -222,7 +222,7 @@ public class IrisWorlds {
dimension = IrisData.loadAnyDimension(id, null);
}
if (dimension == null) {
File packsRoot = IrisPlatforms.get().dataFolderNoCreate(StudioSVC.WORKSPACE_NAME);
File packsRoot = IrisPlatforms.get().packsFolderNoCreate();
if (PackDownloader.isPackPresent(packsRoot, id)) {
IrisLogging.error("Pack '" + id + "' exists at " + new File(packsRoot, id).getPath()
+ " but its dimension failed to load; not redownloading. Fix or delete the pack folder.");
@@ -892,7 +892,7 @@ public class ServerConfigurator {
public static Stream<IrisData> allPacks() {
Stream<File> locals = PackDirectoryResolver.listVisiblePackDirectories(
IrisPlatforms.get().dataFolder("packs")
IrisPlatforms.get().packsFolder()
).stream();
return Stream.concat(locals
.filter(base -> {
@@ -175,7 +175,7 @@ public final class DatapackIngestService {
if (autoIngest && !configured.isEmpty()) {
IrisLogging.info("Validating " + configured.size()
+ " configured external datapack import(s) before player admission...");
Report report = ingest(null, configured, true);
Report report = ingest(null, configured, true, true);
if (!report.getFailed().isEmpty()) {
String failure = report.getFailed().getFirst();
IrisStartupValidation.markDatapacksInvalid(failure);
@@ -513,15 +513,35 @@ public final class DatapackIngestService {
}
public static Report ingest(VolmitSender sender, KList<String> urls, boolean restart) {
IrisStartupValidation.beginDatapackValidation();
return ingest(sender, urls, restart, false);
}
/**
* Only startup validation owns the global player-admission gate (gateAdmission=true). The
* runtime admin command must never flip it: a transient download failure there would lock
* every login until restart, and the PENDING window would close logins for the whole
* (potentially very slow) download. World-creation safety on the ungated path is carried
* by the loaded-runtime invalidation plus requireDatapackRestart when files change.
*/
private static Report ingest(VolmitSender sender, KList<String> urls, boolean restart, boolean gateAdmission) {
if (gateAdmission) {
IrisStartupValidation.beginDatapackValidation();
}
ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation =
ServerConfigurator.invalidateLoadedDatapackRuntime();
Report report;
boolean settled = false;
TRANSACTION_LOCK.lock();
try {
report = ingestLocked(sender, urls, restart);
settled = true;
} finally {
TRANSACTION_LOCK.unlock();
if (!settled && gateAdmission) {
// Never strand the admission gate at PENDING on an unexpected throw.
IrisStartupValidation.markDatapacksInvalid(
"External datapack ingest failed unexpectedly; check the log above.");
}
}
if (!report.changed() && report.getFailed().isEmpty()) {
ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation);
@@ -532,10 +552,12 @@ public final class DatapackIngestService {
ServerConfigurator.requireDatapackRestart();
}
}
if (!report.getFailed().isEmpty()) {
IrisStartupValidation.markDatapacksInvalid(report.getFailed().getFirst());
} else if (!report.changed()) {
IrisStartupValidation.markDatapacksReady();
if (gateAdmission) {
if (!report.getFailed().isEmpty()) {
IrisStartupValidation.markDatapacksInvalid(report.getFailed().getFirst());
} else if (!report.changed()) {
IrisStartupValidation.markDatapacksReady();
}
}
return report;
}
@@ -944,45 +966,61 @@ public final class DatapackIngestService {
}
}
enum RemoveOutcome {
REMOVED,
REJECTED,
FAILED_AFTER_MUTATION
}
public static boolean remove(VolmitSender sender, String id) {
ServerConfigurator.invalidateLoadedDatapackRuntime();
boolean removed;
ServerConfigurator.LoadedDatapackRuntimeInvalidation invalidation =
ServerConfigurator.invalidateLoadedDatapackRuntime();
RemoveOutcome outcome;
TRANSACTION_LOCK.lock();
try {
removed = removeLocked(sender, id);
outcome = removeOutcomeLocked(sender, id);
} finally {
TRANSACTION_LOCK.unlock();
}
if (removed) {
if (outcome == RemoveOutcome.REMOVED) {
ServerConfigurator.requireDatapackRestart();
} else if (outcome == RemoveOutcome.REJECTED) {
// Rejected before any mutation: nothing on disk changed, so the loaded runtime is
// still valid. Leaving it invalidated forced a full datapack reinstall on every
// later world creation for the rest of the session.
ServerConfigurator.restoreLoadedDatapackRuntimeIfUnchanged(invalidation);
}
return removed;
return outcome == RemoveOutcome.REMOVED;
}
private static boolean removeLocked(VolmitSender sender, String id) {
private static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id) {
File root = IrisPlatforms.get().dataFolder("datapacks");
return removeLocked(sender, id, root, ServerConfigurator.getDatapacksFolder());
return removeOutcomeLocked(sender, id, root, ServerConfigurator.getDatapacksFolder());
}
static boolean removeLocked(VolmitSender sender, String id, File root, List<File> worldFolders) {
return removeOutcomeLocked(sender, id, root, worldFolders) == RemoveOutcome.REMOVED;
}
static RemoveOutcome removeOutcomeLocked(VolmitSender sender, String id, File root, List<File> worldFolders) {
String requested = id == null ? "" : id.trim().toLowerCase(Locale.ROOT);
String cleaned = sanitizeId(id);
if (requested.isBlank() || !requested.equals(cleaned) || RESERVED_IDS.contains(cleaned)) {
message(sender, C.RED + "Invalid Iris-managed datapack id '" + requested + "'. Run /iris datapack list and use the exact listed id.");
return false;
return RemoveOutcome.REJECTED;
}
try {
recoverTransactions(root, worldFolders);
} catch (IOException e) {
message(sender, C.RED + "Datapack removal blocked by incomplete transaction recovery: " + e.getMessage());
IrisLogging.reportError(e);
return false;
return RemoveOutcome.REJECTED;
}
Manifest manifest = readManifest(root);
Entry ownedEntry = manifest.findById(cleaned);
if (ownedEntry == null) {
message(sender, C.YELLOW + "No Iris-managed datapack named '" + cleaned + "'. Unmanaged world datapacks are never removed by Iris.");
return false;
return RemoveOutcome.REJECTED;
}
List<File> targets;
@@ -991,7 +1029,7 @@ public final class DatapackIngestService {
} catch (IOException e) {
message(sender, C.RED + "Refused to remove datapack '" + cleaned + "': " + e.getMessage());
IrisLogging.reportError(e);
return false;
return RemoveOutcome.REJECTED;
}
EditableImportRemoval editableRemoval = null;
@@ -1017,7 +1055,7 @@ public final class DatapackIngestService {
removalFailure);
message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN
+ "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone.");
return true;
return RemoveOutcome.REMOVED;
}
boolean restored = rollbackRemoval(manifestWrite, directoryRemoval, editableRemoval, removalFailure);
if (restored && coordinator != null) {
@@ -1030,12 +1068,14 @@ public final class DatapackIngestService {
message(sender, C.RED + "Failed to remove datapack '" + cleaned
+ "'; Iris attempted to restore every prior location: " + removalFailure.getMessage());
IrisLogging.reportError(removalFailure);
return false;
// A mutation was attempted; even a successful rollback is not provably identical
// (the fingerprint does not cover datapacks/), so stay conservatively invalidated.
return RemoveOutcome.FAILED_AFTER_MUTATION;
}
finishCommittedRemoval(cleaned, manifestWrite, directoryRemoval, editableRemoval, coordinator, null);
message(sender, C.GREEN + "Removed datapack '" + C.WHITE + cleaned + C.GREEN
+ "'. Restart for it to stop generating, and delete its URL from the pack's datapackImports to keep it gone.");
return true;
return RemoveOutcome.REMOVED;
}
private static void finishCommittedRemoval(
@@ -358,15 +358,18 @@ public final class ModrinthResolver {
connection.setReadTimeout(20000);
connection.setInstanceFollowRedirects(true);
int code = connection.getResponseCode();
if (code != 200) {
connection.disconnect();
throw new IOException("HTTP " + code + " from " + url);
}
// Everything past openConnection under one finally: a timeout/DNS/TLS throw inside
// getResponseCode abandoned the connection undisconnected.
try {
int code = connection.getResponseCode();
if (code != 200) {
throw new IOException("HTTP " + code + " from " + url);
}
try (InputStream input = connection.getInputStream();
InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) {
return readBoundedApiResponse(reader, url, MAX_API_RESPONSE_CHARS);
try (InputStream input = connection.getInputStream();
InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) {
return readBoundedApiResponse(reader, url, MAX_API_RESPONSE_CHARS);
}
} finally {
connection.disconnect();
}
@@ -36,9 +36,17 @@ public class BlockSignal {
public static final AtomicInteger active = new AtomicInteger(0);
public BlockSignal(Block block, int ticks) {
active.incrementAndGet();
// Count only after the entity actually exists, and release unconditionally: a refused
// Folia schedule or a spawn failure must not inflate the throttle counter forever.
Location tg = block.getLocation().clone().add(0.5, 0, 0.5);
FallingBlock e = block.getWorld().spawnFallingBlock(tg, block.getBlockData());
FallingBlock e;
try {
e = block.getWorld().spawnFallingBlock(tg, block.getBlockData());
} catch (Throwable spawnFailure) {
sendBlockRefresh(block);
throw spawnFailure;
}
active.incrementAndGet();
e.setGravity(false);
e.setInvulnerable(true);
e.setGlowing(true);
@@ -55,10 +63,11 @@ public class BlockSignal {
active.decrementAndGet();
sendBlockRefresh(block);
};
if (!J.runAt(blockLocation, removeTask, ticks)) {
if (!J.isFolia()) {
J.s(removeTask, ticks);
}
boolean scheduled = J.runAt(blockLocation, removeTask, ticks);
if (!scheduled && !J.isFolia()) {
J.s(removeTask, ticks);
} else if (!scheduled) {
removeTask.run();
}
}
@@ -36,7 +36,6 @@ import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import lombok.Data;
import org.bukkit.Location;
import org.bukkit.Sound;
import org.bukkit.World;
@@ -47,74 +46,83 @@ import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import java.util.ArrayDeque;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Supplier;
@SuppressWarnings("ALL")
@Data
public class DustRevealer {
private final Engine engine;
private final World world;
private final BlockPosition block;
private final String key;
private final KList<BlockPosition> hits;
// Matches the modded twin (ModdedDustRevealer) so both platforms truncate identically.
private static final int MAX_HITS = 2_048;
private static final int PARTICLE_BATCH_SIZE = 64;
public DustRevealer(Engine engine, World world, BlockPosition block, String key, KList<BlockPosition> hits) {
this.engine = engine;
this.world = world;
this.block = block;
this.key = key;
this.hits = hits;
/**
* Bounded, single-threaded flood fill over the object's placement key, followed by batched
* particle playback on the owning threads. The previous shape recursively spawned one
* scheduler task + one burst task per matched block, all mutating one unsynchronized
* ArrayList and sleep-spinning MultiBurst workers — unbounded fan-out on large objects.
*/
private static void reveal(Engine engine, World world, BlockPosition origin, String key) {
KList<BlockPosition> hits = new KList<>();
Set<BlockPosition> visited = new HashSet<>();
ArrayDeque<BlockPosition> frontier = new ArrayDeque<>();
visited.add(origin);
frontier.add(origin);
hits.add(origin);
int minY = world.getMinHeight();
int maxY = world.getMaxHeight();
Location blockLocation = block.toBlock(world).getLocation();
Runnable revealTask = () -> {
BlockSignal.of(world, block.getX(), block.getY(), block.getZ(), 10);
try {
search:
while (!frontier.isEmpty()) {
BlockPosition at = frontier.poll();
for (int dx = -1; dx <= 1; dx++) {
for (int dy = -1; dy <= 1; dy++) {
for (int dz = -1; dz <= 1; dz++) {
if (dx == 0 && dy == 0 && dz == 0) {
continue;
}
BlockPosition next = new BlockPosition(at.getX() + dx, at.getY() + dy, at.getZ() + dz);
if (next.getY() < minY || next.getY() >= maxY || !visited.add(next)) {
continue;
}
if (key.equals(engine.getObjectPlacementKey(next.getX(), next.getY() - minY, next.getZ()))) {
hits.add(next);
frontier.add(next);
if (hits.size() >= MAX_HITS) {
break search;
}
}
}
}
}
}
} catch (Throwable e) {
IrisLogging.reportError(e);
}
revealBatch(world, hits, 0);
}
private static void revealBatch(World world, KList<BlockPosition> hits, int from) {
if (from >= hits.size()) {
return;
}
int to = Math.min(from + PARTICLE_BATCH_SIZE, hits.size());
Runnable batch = () -> {
for (int i = from; i < to; i++) {
BlockPosition p = hits.get(i);
BlockSignal.of(world, p.getX(), p.getY(), p.getZ(), 10);
}
BlockPosition first = hits.get(from);
if (M.r(0.25)) {
world.playSound(block.toBlock(world).getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 1f, RNG.r.f(0.2f, 2f));
world.playSound(first.toBlock(world).getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 1f, RNG.r.f(0.2f, 2f));
}
J.a(() -> {
while (BlockSignal.active.get() > 128) {
J.sleep(5);
}
try {
is(new BlockPosition(block.getX() + 1, block.getY(), block.getZ()));
is(new BlockPosition(block.getX() - 1, block.getY(), block.getZ()));
is(new BlockPosition(block.getX(), block.getY() + 1, block.getZ()));
is(new BlockPosition(block.getX(), block.getY() - 1, block.getZ()));
is(new BlockPosition(block.getX(), block.getY(), block.getZ() + 1));
is(new BlockPosition(block.getX(), block.getY(), block.getZ() - 1));
is(new BlockPosition(block.getX() + 1, block.getY(), block.getZ() + 1));
is(new BlockPosition(block.getX() + 1, block.getY(), block.getZ() - 1));
is(new BlockPosition(block.getX() - 1, block.getY(), block.getZ() + 1));
is(new BlockPosition(block.getX() - 1, block.getY(), block.getZ() - 1));
is(new BlockPosition(block.getX() + 1, block.getY() + 1, block.getZ()));
is(new BlockPosition(block.getX() + 1, block.getY() - 1, block.getZ()));
is(new BlockPosition(block.getX() - 1, block.getY() + 1, block.getZ()));
is(new BlockPosition(block.getX() - 1, block.getY() - 1, block.getZ()));
is(new BlockPosition(block.getX(), block.getY() + 1, block.getZ() - 1));
is(new BlockPosition(block.getX(), block.getY() + 1, block.getZ() + 1));
is(new BlockPosition(block.getX(), block.getY() - 1, block.getZ() - 1));
is(new BlockPosition(block.getX(), block.getY() - 1, block.getZ() + 1));
is(new BlockPosition(block.getX() - 1, block.getY() + 1, block.getZ() - 1));
is(new BlockPosition(block.getX() - 1, block.getY() + 1, block.getZ() + 1));
is(new BlockPosition(block.getX() - 1, block.getY() - 1, block.getZ() - 1));
is(new BlockPosition(block.getX() - 1, block.getY() - 1, block.getZ() + 1));
is(new BlockPosition(block.getX() + 1, block.getY() + 1, block.getZ() - 1));
is(new BlockPosition(block.getX() + 1, block.getY() + 1, block.getZ() + 1));
is(new BlockPosition(block.getX() + 1, block.getY() - 1, block.getZ() - 1));
is(new BlockPosition(block.getX() + 1, block.getY() - 1, block.getZ() + 1));
} catch (Throwable e) {
IrisLogging.reportError(e);
e.printStackTrace();
}
});
revealBatch(world, hits, to);
};
int delay = RNG.r.i(2, 8);
if (!J.runAt(blockLocation, revealTask, delay)) {
if (!J.isFolia()) {
J.s(revealTask, delay);
}
Location at = hits.get(from).toBlock(world).getLocation();
if (!J.runAt(at, batch, 1) && !J.isFolia()) {
J.s(batch, 1);
}
}
@@ -137,9 +145,7 @@ public class DustRevealer {
RuntimeUiMessages.DUST_FOUND_OBJECT,
MessageArgument.untrusted("object", a)
));
J.a(() -> {
new DustRevealer(access, world, new BlockPosition(block.getX(), block.getY(), block.getZ()), a, new KList<>());
});
J.a(() -> reveal(access, world, new BlockPosition(block.getX(), block.getY(), block.getZ()), a));
}
}
}
@@ -354,24 +360,6 @@ public class DustRevealer {
}
}
private boolean is(BlockPosition a) {
if (a.getY() < world.getMinHeight() || a.getY() >= world.getMaxHeight()) {
return false;
}
int betterY = a.getY() - world.getMinHeight();
if (isValidTry(a) && engine.getObjectPlacementKey(a.getX(), betterY, a.getZ()) != null && engine.getObjectPlacementKey(a.getX(), betterY, a.getZ()).equals(key)) {
hits.add(a);
new DustRevealer(engine, world, a, key, hits);
return true;
}
return false;
}
private boolean isValidTry(BlockPosition b) {
return !hits.contains(b);
}
private record DustLine(String text, boolean emphasis) {
}
}
@@ -42,7 +42,10 @@ import java.util.concurrent.locks.ReentrantLock;
public final class PregenRenderer extends JPanel implements KeyListener {
private static final long serialVersionUID = 2094606939770332040L;
private final KList<Runnable> order = new KList<>();
// Backstop: if paint() ever stalls (iconified frame, EDT hiccup), the producer must not
// grow this without bound — one entry per drawn chunk adds up fast on a big pregen.
private static final int MAX_QUEUED_DRAWS = 250_000;
private KList<Runnable> order = new KList<>();
private final ReentrantLock lock = new ReentrantLock();
private final int res = 512;
private final BufferedImage image = new BufferedImage(res, res, BufferedImage.TYPE_INT_RGB);
@@ -71,7 +74,9 @@ public final class PregenRenderer extends JPanel implements KeyListener {
return (Position2 c, Color color) -> {
lock.lock();
try {
order.add(() -> draw(c, color, bg));
if (order.size() < MAX_QUEUED_DRAWS) {
order.add(() -> draw(c, color, bg));
}
} finally {
lock.unlock();
}
@@ -83,7 +88,11 @@ public final class PregenRenderer extends JPanel implements KeyListener {
}
public boolean isVisibleFrame() {
return frame != null && frame.isVisible();
// An iconified frame still reports isVisible() but AWT stops repainting it, which
// would stop the only queue drain while the producer keeps appending.
JFrame activeFrame = frame;
return activeFrame != null && activeFrame.isVisible()
&& (activeFrame.getExtendedState() & java.awt.Frame.ICONIFIED) == 0;
}
public void close() {
@@ -99,18 +108,23 @@ public final class PregenRenderer extends JPanel implements KeyListener {
public void paint(Graphics gx) {
Graphics2D g = (Graphics2D) gx;
bg = (Graphics2D) image.getGraphics();
// Swap the queue under the lock and drain outside it: generation threads must not
// block on the lock while the EDT walks a large batch, and pop()-per-entry was O(N^2).
KList<Runnable> batch;
lock.lock();
try {
while (order.isNotEmpty()) {
try {
order.pop().run();
} catch (Throwable e) {
IrisLogging.reportError(e);
}
}
batch = order;
order = new KList<>();
} finally {
lock.unlock();
}
for (Runnable r : batch) {
try {
r.run();
} catch (Throwable e) {
IrisLogging.reportError(e);
}
}
g.drawImage(image, 0, 0, getParent().getWidth(), getParent().getHeight(), (img, infoflags, x, y, width, height) -> true);
g.setColor(Color.WHITE);
@@ -38,7 +38,6 @@ import art.arcane.volmlib.util.function.Consumer2;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.math.Position2;
import art.arcane.volmlib.util.scheduling.ChronoLatch;
import art.arcane.iris.util.common.scheduling.J;
import java.awt.Color;
@@ -46,12 +45,14 @@ import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
public class PregeneratorJob implements PregenListener, PregenRenderSource {
private static final long WORLD_SHUTDOWN_TIMEOUT_MILLIS = 15_000L;
// Must exceed the worker's own worst-case teardown budget (AsyncPregenMethod close:
// 60s permit drain + 120s flush + plate reclaim), or a routine drain trips the deadline
// and aborts the engine shutdown sequence mid-teardown.
private static final long WORLD_SHUTDOWN_TIMEOUT_MILLIS = 200_000L;
private static final Color COLOR_EXISTS = parseColor("#4d7d5b");
private static final Color COLOR_BLACK = parseColor("#4d7d5b");
private static final Color COLOR_MANTLE = parseColor("#3c2773");
@@ -69,14 +70,12 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
private final IrisPregenerator pregenerator;
private final Position2 min;
private final Position2 max;
private final ChronoLatch cl = new ChronoLatch(TimeUnit.MINUTES.toMillis(1));
private final Engine engine;
private final ExecutorService service;
private final Thread worker;
private final PregenPhaseTracker apiPhases = new PregenPhaseTracker();
private PregenRenderer renderer;
private Consumer2<Position2, Color> drawFunction;
private int rgc = 0;
private String[] info;
private volatile double lastChunksPerSecond = 0D;
private volatile long lastChunksRemaining = 0L;
@@ -87,14 +86,6 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
private volatile String lastMethod = IrisLanguage.plain(DesktopUiMessages.PREGEN_METHOD_PENDING);
public PregeneratorJob(PregenTask task, PregeneratorMethod method, Engine engine) {
instance.updateAndGet(old -> {
if (old != null) {
old.pregenerator.close();
old.worker.interrupt();
old.close();
}
return this;
});
this.engine = engine;
monitor = new MemoryMonitor(50);
saving = false;
@@ -120,6 +111,19 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
worker.setPriority(Thread.MIN_PRIORITY);
worker.setDaemon(true);
worker.setUncaughtExceptionHandler((thread, ex) -> IrisLogging.reportError(ex));
// Publish into the static only after every field is assigned (the volatile swap is
// what makes them visible to metrics/shutdown readers), and start the worker after
// publication so it also sees a complete object.
// CAS-or-throw: updateAndGet must be side-effect free (it can re-apply on
// contention), and silently killing the previous job overlapped its 60s+120s
// teardown with the new job's generation. Replacement goes through
// shutdownAndWait first, matching the modded adapter's rejection contract.
if (!instance.compareAndSet(null, this)) {
monitor.close();
service.shutdown();
throw new IllegalStateException("An Iris pregeneration job is already running; stop it first.");
}
worker.start();
}
@@ -138,6 +142,14 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
return false;
}
if (!inst.worker.isAlive() && inst.worker.getState() != Thread.State.NEW) {
// The worker died without running onClose (early abort); clear the phantom job so
// it stops suppressing entity spawns and blocking future pregens. A NEW worker is
// a job mid-construction, not a dead one.
instance.compareAndSet(inst, null);
return false;
}
J.a(() -> {
inst.pregenerator.close();
inst.worker.interrupt();
@@ -432,17 +444,11 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
@Override
public void onRegionGenerated(int x, int z) {
shouldGc();
rgc++;
// No forced System.gc() here: a wall-clock full STW collection mid-generation stalled
// everything; MantleHeapPressure's 96% panic reclaim already owns heap pressure.
broadcastRegionDelta(x, z, IrisMessage.PregenRegionDelta.STATE_DONE);
}
private void shouldGc() {
if (cl.flip() && rgc > 16) {
System.gc();
}
}
private void broadcastRegionDelta(int regionX, int regionZ, int state) {
IrisProtocolServer protocolServer = IrisServices.getOrNull(IrisProtocolServer.class);
if (protocolServer == null) {
@@ -24,6 +24,7 @@ import art.arcane.iris.engine.framework.render.IrisRenderer;
import art.arcane.iris.engine.framework.render.RenderType;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.PreservationRegistry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisRegion;
@@ -281,7 +282,7 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
}
public boolean updateEngine() {
if (engine.isClosed()) {
if (engine.isClosed() || engine.isClosing() || engine.getComplex() == null) {
try {
Engine reacquired = GuiHost.get().findActiveEngine();
if (reacquired != null && !reacquired.isClosed()) {
@@ -451,12 +452,19 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
double mk = mscale;
double mkd = scale;
e.submit(() -> {
PrecisionStopwatch ps = PrecisionStopwatch.start();
BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / (lowtile ? 3 : 1), currentType);
rs.put(ps.getMilliseconds());
working.remove(key);
if (mk == mscale && mkd == scale) {
positions.put(key, b);
// finally: a render throw is swallowed by the discarded Future, and a
// leaked key permanently blocks the admission gate (working < 9).
try {
PrecisionStopwatch ps = PrecisionStopwatch.start();
BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / (lowtile ? 3 : 1), currentType);
rs.put(ps.getMilliseconds());
if (mk == mscale && mkd == scale) {
positions.put(key, b);
}
} catch (Throwable ex) {
IrisLogging.debug("Vision tile render failed: " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
} finally {
working.remove(key);
}
});
}
@@ -472,12 +480,17 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
double mk = mscale;
double mkd = scale;
eh.submit(() -> {
PrecisionStopwatch ps = PrecisionStopwatch.start();
BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / lowq, currentType);
rs.put(ps.getMilliseconds());
workingfast.remove(key);
if (mk == mscale && mkd == scale) {
fastpositions.put(key, b);
try {
PrecisionStopwatch ps = PrecisionStopwatch.start();
BufferedImage b = renderer.render(x * mscale, z * mscale, div * mscale, div / lowq, currentType);
rs.put(ps.getMilliseconds());
if (mk == mscale && mkd == scale) {
fastpositions.put(key, b);
}
} catch (Throwable ex) {
IrisLogging.debug("Vision tile render failed: " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
} finally {
workingfast.remove(key);
}
});
return null;
@@ -507,12 +520,30 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
@Override
public void paint(Graphics gx) {
if (engine.isClosed() && !updateEngine()) {
// Cover the closing window too: releaseRuntime nulls the complex BEFORE closed flips,
// and a paint NPE in that window killed the self-driven repaint loop forever.
if ((engine.isClosed() || engine.isClosing() || engine.getComplex() == null) && !updateEngine()) {
EventQueue.invokeLater(() -> {
try { setVisible(false); } catch (Throwable ignored) { }
});
return;
}
try {
paintBody(gx);
} catch (Throwable e) {
IrisLogging.debug("Vision paint failed: " + e.getClass().getSimpleName() + ": " + e.getMessage());
} finally {
// The repaint loop is entirely self-driven from here; it must survive any render
// exception or the window freezes on a stale frame permanently.
long sleepMs = eco ? 32 : 16;
J.a(() -> {
J.sleep(sleepMs);
repaint();
});
}
}
private void paintBody(Graphics gx) {
velocity = Math.abs(ox - oxp) * 0.36 + Math.abs(oz - ozp) * 0.36;
oxp = lerp(oxp, ox, 0.36);
@@ -587,6 +618,16 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
}
}
// Bounded, not pruned-to-frame: the low-quality cache is the pan placeholder, but
// unbounded growth while panning leaked one BufferedImage per visited tile forever.
if (fastpositions.size() > 4096) {
for (BlockPosition i : fastpositions.k()) {
if (!gg.contains(i)) {
fastpositions.remove(i);
}
}
}
handleFollow();
renderOverlays(g, p.getMilliseconds());
@@ -594,12 +635,6 @@ public class VisionGUI extends JPanel implements MouseWheelListener, KeyListener
return;
}
long targetMs = eco ? 32 : 16;
long sleepMs = Math.max(1, targetMs - (long) p.getMilliseconds());
J.a(() -> {
J.sleep(sleepMs);
repaint();
});
}
private void renderGrid(Graphics2D g, int tileSize, double offsetX, double offsetZ) {
@@ -768,7 +768,7 @@ public final class IrisWorldRemovalService {
public CompletableFuture<Void> endMaintenance(ResolvedWorld resolvedWorld) {
World world = resolvedWorld.loadedWorld();
if (world != null) {
IrisToolbelt.endWorldMaintenance(world, "world-remove");
IrisToolbelt.endWorldMaintenance(world, "world-remove", true);
}
return CompletableFuture.completedFuture(null);
}
@@ -3,14 +3,17 @@ package art.arcane.iris.core.link;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.engine.data.cache.AtomicCache;
import art.arcane.volmlib.util.data.Cuboid;
import art.arcane.volmlib.util.scheduling.ChronoLatch;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.entity.Player;
import java.lang.reflect.InvocationTargetException;
import java.util.function.BooleanSupplier;
public class WorldEditLink {
private static final AtomicCache<Boolean> active = new AtomicCache<>();
private static final ChronoLatch errorThrottle = new ChronoLatch(60_000);
public static Cuboid getSelection(Player p) {
if (!hasWorldEdit())
@@ -42,16 +45,40 @@ public class WorldEditLink {
(int) min.getClass().getDeclaredMethod("z").invoke(max)
);
} catch (Throwable e) {
IrisLogging.error("Could not get selection");
e.printStackTrace();
IrisLogging.reportError(e);
active.reset();
active.aquire(() -> false);
if (errorThrottle.flip()) {
IrisLogging.error("Could not get selection");
e.printStackTrace();
IrisLogging.reportError(e);
}
invalidate();
}
return null;
}
public static boolean hasWorldEdit() {
return active.aquire(() -> Bukkit.getPluginManager().isPluginEnabled("WorldEdit"));
return hasWorldEdit(() -> Bukkit.getPluginManager().isPluginEnabled("WorldEdit"));
}
static boolean hasWorldEdit(BooleanSupplier detector) {
if (Boolean.TRUE.equals(active.getIfPresent())) {
return true;
}
boolean present;
try {
present = detector.getAsBoolean();
} catch (Throwable ignored) {
return false;
}
if (present) {
active.aquire(() -> Boolean.TRUE);
}
return present;
}
static void invalidate() {
active.reset();
}
}
@@ -43,6 +43,7 @@ import art.arcane.iris.engine.object.IrisBlockData;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisEntity;
import art.arcane.iris.engine.object.IrisExpression;
import art.arcane.iris.engine.object.IrisObjectScale;
import art.arcane.iris.engine.object.IrisGenerator;
import art.arcane.iris.engine.object.IrisImage;
import art.arcane.iris.engine.object.IrisJigsawPiece;
@@ -86,12 +87,20 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@Data
public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
private static final Map<File, IrisData> dataLoaders = new ConcurrentHashMap<>();
// Loaders (cached or detached) that currently have registered engines; see hasActiveEngines.
// Identity-keyed on purpose: Lombok's @Data hashCode over this class's mutable loader state
// drifts while an engine runs, so a hashing set would silently fail removal and pin every
// engine-hosting IrisData (and its pack graph) for the JVM lifetime.
private static final Set<IrisData> ENGINE_HOLDERS =
Collections.synchronizedSet(Collections.newSetFromMap(new IdentityHashMap<>()));
private final File dataFolder;
private final int id;
private final boolean datapackCompiler;
@@ -262,7 +271,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
}
for (File i : PackDirectoryResolver.listVisiblePackDirectories(
IrisPlatforms.get().dataFolder("packs"))) {
IrisPlatforms.get().packsFolder())) {
IrisData dm = get(i);
if (dm == nearest) continue;
T t = dm.load(type, key, false);
@@ -338,6 +347,30 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
}
}
/**
* True when any loader — including detached {@link #openRuntime(File)} instances, which
* never enter the dataLoaders cache — has a live engine reading the given pack folder.
* The dataLoaders cache cannot answer this question: engines only ever attach to
* openRuntime instances.
*/
public static boolean hasActiveEngines(File dataFolder) {
Path target = dataFolder.toPath().toAbsolutePath().normalize();
IrisData[] holders;
synchronized (ENGINE_HOLDERS) {
holders = ENGINE_HOLDERS.toArray(new IrisData[0]);
}
for (IrisData data : holders) {
if (data.getEngines().isEmpty()) {
ENGINE_HOLDERS.remove(data);
continue;
}
if (data.getDataFolder().toPath().toAbsolutePath().normalize().equals(target)) {
return true;
}
}
return false;
}
public void registerEngine(Engine engine) {
Objects.requireNonNull(engine, "engine");
synchronized (engines) {
@@ -348,6 +381,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
}
engines.add(engine);
}
ENGINE_HOLDERS.add(this);
}
public void unregisterEngine(Engine engine) {
@@ -359,9 +393,12 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
while (iterator.hasNext()) {
if (iterator.next() == engine) {
iterator.remove();
return;
break;
}
}
if (engines.isEmpty()) {
ENGINE_HOLDERS.remove(this);
}
}
}
@@ -383,6 +420,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
synchronized (engines) {
engines.clear();
}
ENGINE_HOLDERS.remove(this);
dataLoaders.remove(dataFolder, this);
}
@@ -425,6 +463,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
public synchronized void hotloaded() {
StructureGraphCatalog.invalidate(this);
IrisObjectScale.invalidate();
closed = false;
possibleSnippets = new KMap<>();
builder = new GsonBuilder()
@@ -490,6 +529,7 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
public void dump() {
StructureGraphCatalog.invalidate(this);
IrisObjectScale.invalidate();
for (ResourceLoader<?> i : loaders.values()) {
i.clearCache();
}
@@ -63,8 +63,8 @@ import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Predicate;
@@ -79,14 +79,8 @@ import java.util.zip.GZIPOutputStream;
public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
public static final AtomicDouble tlt = new AtomicDouble(0);
private static final int CACHE_SIZE = 100000;
private static final ExecutorService schemaBuildExecutor = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Iris-Schema-Builder");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
});
private static volatile ExecutorService schemaBuildExecutor;
private static final Set<String> schemaBuildQueue = ConcurrentHashMap.newKeySet();
private static final AtomicBoolean schemaBuildExecutorRegistered = new AtomicBoolean();
protected final AtomicCache<KList<File>> folderCache;
protected volatile KSet<String> firstAccess;
protected File root;
@@ -124,11 +118,32 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
IrisLogging.debug("Loader<" + C.GREEN + resourceTypeName + C.LIGHT_PURPLE + "> created in " + C.RED + "IDM/" + manager.getId() + C.LIGHT_PURPLE + " on " + C.GRAY + manager.getDataFolder().getPath());
if (options.registerPreservation()) {
IrisServices.get(PreservationRegistry.class).registerCache(this);
}
}
/**
* Lazily (re)creates the schema-build pool. PreservationSVC shutdownNow()s every
* registered executor on plugin disable; a static final pool registered once was dead for
* the rest of the JVM after a hot reload, breaking every studio workspace refresh. Each
* created pool is registered with the CURRENT cycle's preservation registry.
*/
private static synchronized ExecutorService schemaBuildExecutor() {
ExecutorService current = schemaBuildExecutor;
if (current == null || current.isShutdown()) {
schemaBuildQueue.clear();
current = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Iris-Schema-Builder");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
});
schemaBuildExecutor = current;
PreservationRegistry preservation = IrisServices.getOrNull(PreservationRegistry.class);
if (preservation != null && schemaBuildExecutorRegistered.compareAndSet(false, true)) {
preservation.register(schemaBuildExecutor);
if (preservation != null) {
preservation.register(current);
}
}
return current;
}
public JSONObject buildSchema() {
@@ -145,15 +160,22 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
File a = new File(getManager().getDataFolder(), ".iris/schema/" + getFolderName() + "-schema.json");
String schemaPath = a.getAbsolutePath();
if (schemaBuildQueue.add(schemaPath)) {
schemaBuildExecutor.execute(() -> {
try {
IO.writeAll(a, new SchemaBuilder(objectClass, manager).construct().toString(4));
} catch (Throwable e) {
IrisLogging.reportError(e);
} finally {
schemaBuildQueue.remove(schemaPath);
}
});
try {
schemaBuildExecutor().execute(() -> {
try {
IO.writeAll(a, new SchemaBuilder(objectClass, manager).construct().toString(4));
} catch (Throwable e) {
IrisLogging.reportError(e);
} finally {
schemaBuildQueue.remove(schemaPath);
}
});
} catch (RejectedExecutionException rejected) {
// Never let a dead pool leak the queue entry or escape into workspace
// generation (which would delete the user's .code-workspace on the way out).
schemaBuildQueue.remove(schemaPath);
IrisLogging.warn("Schema build skipped (executor unavailable): " + schemaPath);
}
}
return o;
@@ -475,7 +497,9 @@ public class ResourceLoader<T extends IrisRegistrant> implements MeteredCache {
}
KSet<String> set = firstAccess;
if (set != null) set.add(name);
// contains-first: CHM.add takes the bin monitor even when the key is present, and
// every generation thread hammers the same few keys through this line.
if (set != null && !set.contains(name)) set.add(name);
return loadCache.get(name);
}
@@ -148,7 +148,7 @@ public final class DirectorCommandMessages {
);
public static final TextKey COMMAND_DEVELOPER_DIRECTOR_GENERATE_CHUNKS_INTO_BUFFERS_NO_WORLD_WRITES_HASH_BLOCKS_BIOMES_CAPTURES_GOLDEN = TextKey.of(
"iris.director.commanddeveloper.director.generate_chunks_into_buffers_no_world_writes_hash_blocks_biomes_captures_golden",
"Generate chunks into buffers (no world writes) and hash blocks+biomes; captures a golden file or verifies against an existing one. Resets mantle in the scanned area - use on disposable test worlds."
"Generate chunks into buffers (no world writes) and hash blocks+biomes; captures a golden file or verifies against an existing one. Deletes the world's entire mantle - use on disposable test worlds."
);
public static final TextKey COMMAND_DEVELOPER_PARAM_WORLD_SCAN = TextKey.of(
"iris.director.commanddeveloper.param.world_scan",
@@ -168,7 +168,7 @@ public final class DirectorCommandMessages {
);
public static final TextKey COMMAND_DEVELOPER_PARAM_DELETE_MANTLE_DATA_SCAN_AREA_FIRST_FULL_REGENERATION_FROM_SCRATCH = TextKey.of(
"iris.director.commanddeveloper.param.delete_mantle_data_scan_area_first_full_regeneration_from_scratch",
"Delete mantle data in the scan area first for full regeneration from scratch"
"Delete the world's entire mantle folder first for full regeneration from scratch"
);
public static final TextKey COMMAND_DEVELOPER_PARAM_CONCURRENT_CHUNK_GENERATIONS_1_STRICTLY_SERIAL_ORDER_DEPENDENCE_TESTING = TextKey.of(
"iris.director.commanddeveloper.param.concurrent_chunk_generations_1_strictly_serial_order_dependence_testing",
@@ -25,9 +25,37 @@ import org.bukkit.Bukkit;
public class INMS {
//@done
private static final INMSBinding binding = bind();
private static final INMSBinding binding;
private static final Throwable bindFailure;
// A throwing field initializer would leave this class permanently erroneous — every
// later touch raises NoClassDefFoundError and buries the real "unsupported Minecraft
// version" message. Capture the failure so callers can report the actual cause.
static {
INMSBinding bound = null;
Throwable failure = null;
try {
bound = bind();
} catch (Throwable t) {
failure = t;
}
binding = bound;
bindFailure = failure;
}
public static boolean isBound() {
return binding != null;
}
public static Throwable bindFailure() {
return bindFailure;
}
public static INMSBinding get() {
if (binding == null) {
throw new IllegalStateException("Iris NMS binding is unavailable: "
+ (bindFailure == null ? "unknown failure" : bindFailure.getMessage()), bindFailure);
}
return binding;
}
@@ -262,6 +262,13 @@ public interface INMSBinding {
return true;
}
/**
* Removes any instrumentation installed by {@link #injectBukkit()}. Must be idempotent;
* called on plugin disable and pre-unload so the transformer cannot outlive the plugin.
*/
default void uninjectBukkit() {
}
KMap<Material, List<BlockProperty>> getBlockProperties();
private void validateDimensionTypes(WorldCreator c) {
@@ -154,7 +154,7 @@ public class IrisPack {
* @return the file path
*/
public static File packsPack(String name) {
return IrisPlatforms.get().dataFolderNoCreate(StudioSVC.WORKSPACE_NAME, name);
return IrisPlatforms.get().packsFolderNoCreate(name);
}
private static KList<File> collectFiles(File f, String fileExtension) {
@@ -47,7 +47,7 @@ public class IrisPackRepository {
private String repo = "overworld";
@Builder.Default
private String branch = "stable";
private String branch = PackDownloader.DEFAULT_BRANCH;
@Builder.Default
private String tag = "";
@@ -96,7 +96,7 @@ public class IrisPackRepository {
return IrisPackRepository.builder()
.user("IrisDimensions")
.repo(g)
.branch("stable")
.branch(PackDownloader.DEFAULT_BRANCH)
.build();
}
@@ -112,7 +112,7 @@ public class IrisPackRepository {
}
public void install(VolmitSender sender, Runnable whenComplete) throws MalformedURLException {
File pack = IrisPlatforms.get().dataFolderNoCreate(StudioSVC.WORKSPACE_NAME, getRepo());
File pack = IrisPlatforms.get().packsFolderNoCreate(getRepo());
if (!pack.exists()) {
File dl = new File(IrisPlatforms.get().dataFolder("cache", "temp"), "dltk-" + UUID.randomUUID() + ".zip");
@@ -0,0 +1,87 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pack;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* Validates biome layer stacks. caveCeilingLayers reuses the height generators built from layers,
* so a biome with more ceiling entries than surface entries has no generator for the extras; the
* engine skips them, and this validator surfaces the mistake to the author at validate time.
*/
final class PackBiomeLayerValidator {
/** Both layers and caveCeilingLayers default to a single entry when absent (IrisBiome field initializers). */
private static final int DEFAULT_LAYER_COUNT = 1;
private PackBiomeLayerValidator() {
}
static List<String> validateCeilingLayerCounts(File biomesFolder) {
List<String> blockingErrors = new ArrayList<>();
if (biomesFolder == null || !biomesFolder.isDirectory()) {
return blockingErrors;
}
List<File> biomeFiles = PackValidationIo.listJsonRecursive(biomesFolder);
biomeFiles.sort(Comparator.comparing(File::getPath));
for (File biomeFile : biomeFiles) {
String biomeKey = PackValidationIo.deriveKey(biomesFolder, biomeFile);
JSONObject biome;
try {
biome = new JSONObject(Files.readString(biomeFile.toPath(), StandardCharsets.UTF_8));
} catch (Throwable e) {
// Invalid JSON is reported by the graph validators; layer counts have nothing to add.
continue;
}
Integer layers = arrayLength(biome, "layers", biomeKey, blockingErrors);
Integer ceiling = arrayLength(biome, "caveCeilingLayers", biomeKey, blockingErrors);
if (layers == null || ceiling == null) {
continue;
}
if (ceiling > layers) {
blockingErrors.add("Biome '" + biomeKey + "' declares " + ceiling + " caveCeilingLayers but only "
+ layers + " layers. caveCeilingLayers reuses the layers height generators and must not have more entries.");
}
}
return blockingErrors;
}
private static Integer arrayLength(JSONObject biome, String field, String biomeKey, List<String> blockingErrors) {
if (!biome.has(field) || biome.isNull(field)) {
return DEFAULT_LAYER_COUNT;
}
JSONArray array = biome.optJSONArray(field);
if (array == null) {
blockingErrors.add("Biome '" + biomeKey + "' " + field + " must be an array.");
return null;
}
return array.length();
}
}
@@ -18,6 +18,7 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.engine.object.IrisDimensionType;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
@@ -45,6 +46,7 @@ final class PackDimensionValidator {
}
validateImportedStructurePolicy(dimensionKey, dimJson, blockingErrors, warnings);
validateDimensionHeights(packFolder, dimensionKey, dimJson, blockingErrors);
JSONArray regionsArray = dimJson.optJSONArray("regions");
if (regionsArray == null || regionsArray.length() == 0) {
@@ -226,4 +228,84 @@ final class PackDimensionValidator {
}
return resolved;
}
/**
* Mirrors the IrisDimensionType constructor checks so a pack that would throw at world creation
* fails validation instead. Defaults must match the POJOs exactly: dimensionHeight absent means
* the IrisDimension field initializer (-64..320); present-but-partial means the IrisRange field
* initializers (min 16, max 32); logicalHeight absent means 256.
*/
static void validateDimensionHeights(File packFolder, String dimensionKey, JSONObject dimJson, List<String> blockingErrors) {
JSONObject range = resolveDimensionHeight(packFolder, dimJson);
if (range == null && dimJson.has("dimensionHeight") && !dimJson.isNull("dimensionHeight")) {
if (!(dimJson.opt("dimensionHeight") instanceof String)) {
blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight must be an object or a range snippet reference.");
}
// Unresolvable snippet references are reported by the content-key machinery.
return;
}
int minY;
int maxY;
if (range == null) {
minY = -64;
maxY = 320;
} else {
minY = (int) range.optDouble("min", 16D);
maxY = (int) range.optDouble("max", 32D);
}
int height = maxY - minY;
int logicalHeight = dimJson.optInt("logicalHeight", 256);
if (height < IrisDimensionType.MIN_HEIGHT || height > IrisDimensionType.MAX_HEIGHT) {
blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight span (max - min) is " + height
+ "; it must be between " + IrisDimensionType.MIN_HEIGHT + " and " + IrisDimensionType.MAX_HEIGHT + ".");
} else if ((height & (IrisDimensionType.HEIGHT_STEP - 1)) != 0) {
blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight span (max - min) is " + height
+ "; it must be a multiple of " + IrisDimensionType.HEIGHT_STEP + ".");
}
if (minY < IrisDimensionType.MIN_MIN_Y || minY > IrisDimensionType.MAX_MIN_Y) {
blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight.min is " + minY
+ "; it must be between " + IrisDimensionType.MIN_MIN_Y + " and " + IrisDimensionType.MAX_MIN_Y + ".");
} else if ((minY & (IrisDimensionType.HEIGHT_STEP - 1)) != 0) {
blockingErrors.add("Dimension '" + dimensionKey + "' dimensionHeight.min is " + minY
+ "; it must be a multiple of " + IrisDimensionType.HEIGHT_STEP + ".");
}
if (logicalHeight < 0) {
blockingErrors.add("Dimension '" + dimensionKey + "' logicalHeight is " + logicalHeight + "; it cannot be negative.");
} else if (logicalHeight > height) {
blockingErrors.add("Dimension '" + dimensionKey + "' logicalHeight is " + logicalHeight
+ "; it cannot be greater than the dimension height of " + height + ".");
}
}
private static JSONObject resolveDimensionHeight(File packFolder, JSONObject dimJson) {
if (!dimJson.has("dimensionHeight") || dimJson.isNull("dimensionHeight")) {
return null;
}
JSONObject inline = dimJson.optJSONObject("dimensionHeight");
if (inline != null) {
return inline;
}
String reference = dimJson.optString("dimensionHeight", null);
if (reference == null || !reference.startsWith("snippet/")) {
return null;
}
// Mirror IrisData's snippet adapter: canonical snippet/range/... is used verbatim; any other
// snippet/... reference is re-rooted under this field's snippet folder.
if (!reference.startsWith("snippet/range/")) {
reference = "snippet/range/" + reference.substring("snippet/".length());
}
File snippet = new File(packFolder, reference + ".json");
if (!snippet.isFile()) {
return null;
}
try {
return new JSONObject(Files.readString(snippet.toPath(), StandardCharsets.UTF_8));
} catch (Throwable e) {
return null;
}
}
}
@@ -55,6 +55,12 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
public final class PackDownloader {
/**
* The branch every download path falls back to when the caller did not name one. Compile-time
* constant so Director {@code @Param(defaultValue = ...)} annotations can reference it.
*/
public static final String DEFAULT_BRANCH = "stable";
private static final String HEAD_REF = "HEAD";
private static final String DEFAULT_OVERWORLD_PACK = "overworld";
private static final String DEFAULT_OVERWORLD_REPOSITORY = "IrisDimensions/overworld";
private static final String DEFAULT_OVERWORLD_REF = "master";
@@ -234,6 +240,13 @@ public final class PackDownloader {
String url = directUrl ? ref : resolveGithubArchiveUrl(repo, ref);
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " ");
File zip = WebCache.getNonCachedFile("pack-" + repo, url, ARCHIVE_LIMITS.maxArchiveBytes());
if ((zip == null || !zip.exists()) && !directUrl && DEFAULT_BRANCH.equals(ref)) {
// A repo without the shared default branch still resolves via its HEAD; one retry, never recursive.
String headUrl = resolveGithubArchiveUrl(repo, HEAD_REF);
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", headUrl)) + " ");
zip = WebCache.getNonCachedFile("pack-" + repo, headUrl, ARCHIVE_LIMITS.maxArchiveBytes());
url = headUrl;
}
File temp = WebCache.getTemp();
File work = new File(temp, "dl-" + UUID.randomUUID());
@@ -436,17 +449,21 @@ public final class PackDownloader {
+ " (required primary dimension is missing).");
}
Optional<IrisData> loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key()));
if (loadedData.isEmpty()) {
loadedData = IrisData.getLoaded(target.toFile());
}
if (loadedData.isPresent()) {
// A registered loader alone is not "active": startup validation registers a loader for
// every visible pack (permanently), which made force-updating any installed pack
// impossible. Live engines only ever attach to detached openRuntime loaders, so the
// gate must go through the engine index, not the cached loader's own engine list.
// Stale cached registrations are closed so the swap cannot race a loader holding the
// old tree.
if (IrisData.hasActiveEngines(target.toFile())) {
sendFeedback(
feedback,
"Pack '" + prepared.key() + "' is active and cannot be replaced safely. Unload its worlds before retrying."
);
return null;
}
IrisData.getLoaded(new File(packsFolder, prepared.key())).ifPresent(IrisData::close);
IrisData.getLoaded(target.toFile()).ifPresent(IrisData::close);
try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) {
publication.commit();
try {
@@ -471,7 +488,7 @@ public final class PackDownloader {
Set<Path> roots = new LinkedHashSet<>();
roots.add(packsRoot);
if (IrisPlatforms.isBound()) {
roots.add(IrisPlatforms.get().dataFolder("packs").toPath().toAbsolutePath().normalize());
roots.add(IrisPlatforms.get().packsFolder().toPath().toAbsolutePath().normalize());
}
for (Path root : roots) {
if (!Files.isDirectory(root)) {
@@ -0,0 +1,72 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pack;
import art.arcane.iris.engine.object.IrisObjectMarker;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.volmlib.util.collection.KSet;
/**
* Shared key collection for the pack packagers. Both the Bukkit re-serializing compiler and the
* modded verbatim-copy compiler must export the complete ambient-spawning graph: object placements
* carry markers, markers carry spawners, spawners carry entities, entities carry loot. These
* helpers keep the two packagers walking placements identically.
*/
public final class PackExportClosure {
private PackExportClosure() {
}
public static KSet<String> collectMarkerKeys(Iterable<IrisObjectPlacement> placements) {
KSet<String> markerKeys = new KSet<>();
if (placements == null) {
return markerKeys;
}
for (IrisObjectPlacement placement : placements) {
if (placement == null || placement.getMarkers() == null) {
continue;
}
for (IrisObjectMarker marker : placement.getMarkers()) {
if (marker == null || marker.getMarker() == null || marker.getMarker().isBlank()) {
continue;
}
markerKeys.add(marker.getMarker());
}
}
return markerKeys;
}
public static KSet<String> collectObjectKeys(Iterable<IrisObjectPlacement> placements) {
KSet<String> objectKeys = new KSet<>();
if (placements == null) {
return objectKeys;
}
for (IrisObjectPlacement placement : placements) {
if (placement == null || placement.getPlace() == null) {
continue;
}
for (String key : placement.getPlace()) {
if (key == null || key.isBlank()) {
continue;
}
objectKeys.add(key);
}
}
return objectKeys;
}
}
@@ -0,0 +1,165 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pack;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/**
* Warns when two generator files with identical content are both referenced by biomes. IrisGenerator's
* equals ignores the load key and IrisComplex buckets generators into a HashSet, so only one of a
* content-identical pair survives; biomes linked to the losing key resolve 0/0 height bounds and
* contribute a flat band at fluid height.
*
* <p>The fingerprint is a conservative approximation of IrisGenerator.equals built from raw JSON
* (sorted keys, canonical number formatting). It can miss a collision where one file spells out a
* value the other leaves defaulted, and it ignores fields the POJO does not declare - acceptable
* for a warning aimed at the copy-the-file-and-forget-the-seed mistake.
*/
final class PackGeneratorDuplicateValidator {
private static final String GENERATOR_SNIPPET_FOLDER = "snippet/generator-layer/";
private PackGeneratorDuplicateValidator() {
}
static List<String> validateDuplicateGenerators(File packFolder) {
List<String> warnings = new ArrayList<>();
if (packFolder == null || !packFolder.isDirectory()) {
return warnings;
}
File generatorsFolder = new File(packFolder, "generators");
if (!generatorsFolder.isDirectory()) {
return warnings;
}
Set<String> referenced = collectReferencedGeneratorKeys(packFolder);
Map<String, List<String>> byFingerprint = new TreeMap<>();
List<File> generatorFiles = PackValidationIo.listJsonRecursive(generatorsFolder);
generatorFiles.sort(Comparator.comparing(File::getPath));
for (File generatorFile : generatorFiles) {
String key = PackValidationIo.deriveKey(generatorsFolder, generatorFile);
String fingerprint;
try {
fingerprint = fingerprint(new JSONObject(Files.readString(generatorFile.toPath(), StandardCharsets.UTF_8)));
} catch (Throwable e) {
continue;
}
byFingerprint.computeIfAbsent(fingerprint, ignored -> new ArrayList<>()).add(key);
}
for (List<String> group : byFingerprint.values()) {
List<String> referencedKeys = group.stream().filter(referenced::contains).sorted().toList();
if (referencedKeys.size() < 2) {
continue;
}
warnings.add("Generators " + String.join(", ", referencedKeys)
+ " have identical content and are both referenced by biomes. Iris buckets generators by value"
+ " (IrisGenerator equals ignores the load key), so only one survives and biomes referencing the"
+ " others get a zero height band. Give each generator a distinct value (for example a different"
+ " seed) or point every biome at a single key.");
}
return warnings;
}
private static Set<String> collectReferencedGeneratorKeys(File packFolder) {
Set<String> referenced = new HashSet<>();
File biomesFolder = new File(packFolder, "biomes");
if (!biomesFolder.isDirectory()) {
return referenced;
}
for (File biomeFile : PackValidationIo.listJsonRecursive(biomesFolder)) {
JSONObject biome;
try {
biome = new JSONObject(Files.readString(biomeFile.toPath(), StandardCharsets.UTF_8));
} catch (Throwable e) {
continue;
}
JSONArray links = biome.optJSONArray("generators");
if (links == null) {
continue;
}
for (int i = 0; i < links.length(); i++) {
JSONObject link = links.optJSONObject(i);
if (link == null) {
String reference = links.optString(i, null);
link = resolveGeneratorLayerSnippet(packFolder, reference);
if (link == null) {
continue;
}
}
referenced.add(link.optString("generator", "default"));
}
}
return referenced;
}
private static JSONObject resolveGeneratorLayerSnippet(File packFolder, String reference) {
if (reference == null || !reference.startsWith("snippet/")) {
return null;
}
String resolved = reference.startsWith(GENERATOR_SNIPPET_FOLDER)
? reference
: GENERATOR_SNIPPET_FOLDER + reference.substring("snippet/".length());
File snippet = new File(packFolder, resolved + ".json");
if (!snippet.isFile()) {
return null;
}
try {
return new JSONObject(Files.readString(snippet.toPath(), StandardCharsets.UTF_8));
} catch (Throwable e) {
return null;
}
}
private static String fingerprint(Object value) {
if (value instanceof JSONObject object) {
Map<String, String> sorted = new TreeMap<>();
for (String key : object.keySet()) {
sorted.put(key, fingerprint(object.get(key)));
}
StringBuilder builder = new StringBuilder("{");
sorted.forEach((key, child) -> builder.append(key).append('=').append(child).append(';'));
return builder.append('}').toString();
}
if (value instanceof JSONArray array) {
StringBuilder builder = new StringBuilder("[");
for (int i = 0; i < array.length(); i++) {
builder.append(fingerprint(array.get(i))).append(';');
}
return builder.append(']').toString();
}
if (value instanceof Number number) {
// Gson deserializes 1 and 1.0 into the same field value; fingerprint them alike.
return Double.toString(number.doubleValue());
}
return String.valueOf(value);
}
}
@@ -34,10 +34,18 @@ final class PackLootValidator {
private PackLootValidator() {
}
static List<String> validateLootGraph(File packFolder) {
record LootGraphIssues(List<String> errors, List<String> warnings) {
LootGraphIssues {
errors = List.copyOf(errors);
warnings = List.copyOf(warnings);
}
}
static LootGraphIssues validateLootGraph(File packFolder) {
List<String> blockingErrors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
if (packFolder == null || !packFolder.isDirectory()) {
return blockingErrors;
return new LootGraphIssues(blockingErrors, warnings);
}
File lootFolder = new File(packFolder, PackValidator.LOOT_FOLDER);
@@ -69,10 +77,10 @@ final class PackLootValidator {
}
String resourceType = PackStructurePlacementValidator.structureHostType(folderName);
String resourceKey = PackValidationIo.deriveKey(resourceFolder, resourceFile);
validateLootReference(resourceType, resourceKey, resource.opt("loot"), lootKeys, blockingErrors);
validateLootReference(resourceType, resourceKey, resource.opt("loot"), lootKeys, blockingErrors, warnings);
}
}
return blockingErrors;
return new LootGraphIssues(blockingErrors, warnings);
}
private static void validateLootTable(String lootKey, JSONObject table, List<String> blockingErrors) {
@@ -158,17 +166,20 @@ final class PackLootValidator {
}
private static void validateLootReference(String resourceType, String resourceKey, Object rawLoot,
Set<String> lootKeys, List<String> blockingErrors) {
Set<String> lootKeys, List<String> blockingErrors, List<String> warnings) {
String path = resourceType + " '" + resourceKey + "'.loot";
if (!(rawLoot instanceof JSONObject reference)) {
blockingErrors.add(path + " must be an object.");
return;
}
boolean clearMode = false;
if (reference.has("mode")) {
Object rawMode = reference.opt("mode");
if (!(rawMode instanceof String mode)
|| !Set.of("ADD", "CLEAR", "REPLACE", "FALLBACK").contains(mode)) {
blockingErrors.add(path + ".mode must be ADD, CLEAR, REPLACE, or FALLBACK.");
} else {
clearMode = "CLEAR".equals(mode);
}
}
if (reference.has("multiplier")) {
@@ -189,6 +200,10 @@ final class PackLootValidator {
blockingErrors.add(path + ".tables must be an array.");
return;
}
if (clearMode && tables.length() > 0) {
warnings.add(path + " uses mode CLEAR and lists " + tables.length()
+ " table(s); CLEAR clears parent tables and contributes no tables of its own, so these entries are dead. Use REPLACE to substitute them.");
}
for (int tableIndex = 0; tableIndex < tables.length(); tableIndex++) {
Object rawTableKey = tables.opt(tableIndex);
if (!(rawTableKey instanceof String tableKey) || tableKey.isBlank()) {
@@ -0,0 +1,154 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pack;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
/**
* Catches the shared IrisStyledRange class default (min 16, max 32) leaking into pack content.
* The POJO cannot distinguish an empty object from an explicit 16/32 after Gson runs, so the raw
* JSON is the only place this mistake is still visible: an objects[].densityStyle of {} places
* 16-32 objects per chunk, and a caveProfile.densityThreshold of {} hollows the whole cave range.
*/
final class PackStyledRangeDefaultValidator {
private static final String STYLE_RANGE_SNIPPET_FOLDER = "snippet/style-range/";
record Validation(List<String> errors, List<String> warnings) {
Validation {
errors = List.copyOf(errors);
warnings = List.copyOf(warnings);
}
}
private PackStyledRangeDefaultValidator() {
}
static Validation validate(File packFolder) {
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
if (packFolder == null || !packFolder.isDirectory()) {
return new Validation(errors, warnings);
}
for (String folderName : PackValidator.STRUCTURE_HOST_FOLDERS) {
File resourceFolder = new File(packFolder, folderName);
if (!resourceFolder.isDirectory()) {
continue;
}
List<File> resourceFiles = PackValidationIo.listJsonRecursive(resourceFolder);
resourceFiles.sort(Comparator.comparing(File::getPath));
String type = hostType(folderName);
for (File resourceFile : resourceFiles) {
String key = PackValidationIo.deriveKey(resourceFolder, resourceFile);
JSONObject json;
try {
json = new JSONObject(Files.readString(resourceFile.toPath(), StandardCharsets.UTF_8));
} catch (Throwable e) {
continue;
}
validateObjectPlacements(packFolder, type, key, json, errors, warnings);
validateCaveProfileThreshold(packFolder, type, key, json.optJSONObject("caveProfile"), errors, warnings);
}
}
return new Validation(errors, warnings);
}
private static void validateObjectPlacements(File packFolder, String type, String key, JSONObject json,
List<String> errors, List<String> warnings) {
JSONArray objects = json.optJSONArray("objects");
if (objects == null) {
return;
}
for (int i = 0; i < objects.length(); i++) {
JSONObject placement = objects.optJSONObject(i);
if (placement == null || !placement.has("densityStyle") || placement.isNull("densityStyle")) {
continue;
}
checkRange(packFolder, placement.opt("densityStyle"),
type + " '" + key + "' objects[" + i + "].densityStyle",
"16-32 objects per chunk", "remove densityStyle to use the scalar density field",
errors, warnings);
}
}
private static void validateCaveProfileThreshold(File packFolder, String type, String key, JSONObject caveProfile,
List<String> errors, List<String> warnings) {
if (caveProfile == null || !caveProfile.has("densityThreshold") || caveProfile.isNull("densityThreshold")) {
return;
}
checkRange(packFolder, caveProfile.opt("densityThreshold"),
type + " '" + key + "' caveProfile.densityThreshold",
"a carve threshold of 16-32, hollowing the entire vertical range",
"remove densityThreshold to keep the cave profile default",
errors, warnings);
}
private static void checkRange(File packFolder, Object raw, String path, String consequence, String removal,
List<String> errors, List<String> warnings) {
JSONObject range = null;
String via = "";
if (raw instanceof JSONObject inline) {
range = inline;
} else if (raw instanceof String reference && reference.startsWith("snippet/")) {
String resolved = reference.startsWith(STYLE_RANGE_SNIPPET_FOLDER)
? reference
: STYLE_RANGE_SNIPPET_FOLDER + reference.substring("snippet/".length());
File snippet = new File(packFolder, resolved + ".json");
if (!snippet.isFile()) {
return; // Missing snippets are reported by the content-key machinery.
}
try {
range = new JSONObject(Files.readString(snippet.toPath(), StandardCharsets.UTF_8));
via = " (via snippet '" + resolved + "')";
} catch (Throwable e) {
return;
}
}
if (range == null) {
return;
}
boolean hasMin = range.has("min") && !range.isNull("min");
boolean hasMax = range.has("max") && !range.isNull("max");
if (!hasMin && !hasMax) {
errors.add(path + via + " omits both min and max, which resolves to the shared IrisStyledRange default of "
+ consequence + ". Set min and max explicitly, or " + removal + ".");
} else if (!hasMin || !hasMax) {
String missing = hasMin ? "max" : "min";
String defaultValue = hasMin ? "32" : "16";
warnings.add(path + via + " omits " + missing + ", which falls back to the shared IrisStyledRange default of "
+ defaultValue + ". Set it explicitly if that is not intended.");
}
}
private static String hostType(String folderName) {
String singular = folderName.endsWith("s") ? folderName.substring(0, folderName.length() - 1) : folderName;
return singular.substring(0, 1).toUpperCase(Locale.ROOT) + singular.substring(1);
}
}
@@ -77,7 +77,9 @@ public final class PackValidator {
PackDimensionValidator.validateDimensions(packFolder, dimensionFiles, blockingErrors, warnings);
blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder));
blockingErrors.addAll(PackLootValidator.validateLootGraph(packFolder));
PackLootValidator.LootGraphIssues lootIssues = PackLootValidator.validateLootGraph(packFolder);
addDistinct(blockingErrors, lootIssues.errors());
addDistinct(warnings, lootIssues.warnings());
blockingErrors.addAll(PackObjectSurfaceValidator.validateRemovedWorldgenFields(packFolder));
blockingErrors.addAll(PackObjectSurfaceValidator.validateObjectSurfaceSupport(packFolder));
blockingErrors.addAll(PackObjectSurfaceValidator.validateUnsupportedStructureTransforms(packFolder));
@@ -96,6 +98,11 @@ public final class PackValidator {
new File(packFolder, "spawners"), new File(packFolder, "entities")));
blockingErrors.addAll(PackSpawnValidator.validateCustomBiomeSpawns(
new File(packFolder, "biomes"), PackSpawnValidator::resolveEntitySpawnCategory));
blockingErrors.addAll(PackBiomeLayerValidator.validateCeilingLayerCounts(new File(packFolder, "biomes")));
PackStyledRangeDefaultValidator.Validation styledRanges = PackStyledRangeDefaultValidator.validate(packFolder);
addDistinct(blockingErrors, styledRanges.errors());
addDistinct(warnings, styledRanges.warnings());
addDistinct(warnings, PackGeneratorDuplicateValidator.validateDuplicateGenerators(packFolder));
// Strict content mode promotes unresolved keys and bad block properties from advisory to blocking. Palette
// -sourced findings are exempt and stay warnings - see ContentKeyValidator.collectContentKeyIssues.
@@ -199,14 +199,17 @@ public class IrisPregenerator {
}
public void start() {
init();
task.iterateAllChunks((_a, _b) -> totalChunks.incrementAndGet());
startTime.set(M.ms());
ticker.start();
checkRegions();
PrecisionStopwatch p = PrecisionStopwatch.start();
boolean completed = false;
// Everything runs inside the try: an early throw from init/checkRegions must still
// reach shutdown(), or the ticker/monitor threads leak and listener.onClose never
// fires leaving a phantom job that suppresses spawns and blocks future pregens.
try {
init();
task.iterateAllChunks((_a, _b) -> totalChunks.incrementAndGet());
startTime.set(M.ms());
ticker.start();
checkRegions();
int[] regionBounds = task.regionBounds();
generator.onRegionBounds(regionBounds[0], regionBounds[1], regionBounds[2], regionBounds[3]);
task.iterateRegions((x, z) -> visitRegion(x, z, true));
@@ -371,10 +374,13 @@ public class IrisPregenerator {
generator.generateChunk(xx, zz, listener);
});
generator.onRegionSubmitted(x, z);
}
if (hit) {
// Exactly once per visited region, on BOTH branches: a cache-completed region that
// never releases its sentinel wedges neighbor eviction for the whole resume
// frontier (allNeighborsDrained can never pass around it).
generator.onRegionSubmitted(x, z);
listener.onRegionGenerated(x, z);
if (saveLatch.flip()) {
@@ -391,7 +397,9 @@ public class IrisPregenerator {
}
generatedRegions.add(pos);
checkRegions();
// No checkRegions() here: re-spiraling every region after every completed region
// was O(regions^2) dead work on the submitter thread (result discarded, and the
// only side effect was CachedPregenMethod faulting plates against eviction).
}
}
@@ -57,7 +57,11 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
public class AsyncPregenMethod implements PregeneratorMethod {
// THREAD_COUNT records the pool's original size while boosted; BOOST_HOLDERS counts live
// jobs holding the boost. Pairing them per instance stops a dying job (whose close can
// land minutes after its replacement started) from shrinking the pool under the live job.
private static final AtomicInteger THREAD_COUNT = new AtomicInteger();
private static final AtomicInteger BOOST_HOLDERS = new AtomicInteger();
private static final int ADAPTIVE_TIMEOUT_STEP = 3;
private static final int ADAPTIVE_RECOVERY_INTERVAL = 8;
private static final long CLOSE_DRAIN_TIMEOUT_SECONDS = 60L;
@@ -102,6 +106,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private final AtomicLong failed = new AtomicLong();
private final AtomicLong lastProgressAt = new AtomicLong(M.ms());
private final AtomicBoolean closing = new AtomicBoolean();
private final AtomicBoolean holdsWorkerBoost = new AtomicBoolean();
private final Object permitMonitor = new Object();
private volatile Engine metricsEngine;
private volatile Mantle cachedMantle;
@@ -738,8 +743,8 @@ public class AsyncPregenMethod implements PregeneratorMethod {
+ ", recommendedCap=" + recommendedRuntimeConcurrencyCap
+ ", urgent=" + urgent
+ ", timeout=" + timeoutSeconds + "s");
if (workerPoolThreads > 0) {
increaseWorkerThreads();
if (workerPoolThreads > 0 && holdsWorkerBoost.compareAndSet(false, true)) {
acquireWorkerThreadBoost();
}
}
@@ -775,7 +780,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
flushAllRemainingChunks();
executor.shutdown();
resetWorkerThreads();
if (holdsWorkerBoost.compareAndSet(true, false)) {
releaseWorkerThreadBoost();
}
} finally {
if (interrupted) {
Thread.currentThread().interrupt();
@@ -945,7 +952,21 @@ public class AsyncPregenMethod implements PregeneratorMethod {
return null;
}
public static void increaseWorkerThreads() {
public static void acquireWorkerThreadBoost() {
if (BOOST_HOLDERS.getAndIncrement() > 0) {
return;
}
increaseWorkerThreads();
}
public static void releaseWorkerThreadBoost() {
if (BOOST_HOLDERS.decrementAndGet() > 0) {
return;
}
resetWorkerThreads();
}
private static void increaseWorkerThreads() {
THREAD_COUNT.updateAndGet(i -> {
if (i > 0) {
return i;
@@ -974,7 +995,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
});
}
public static void resetWorkerThreads() {
private static void resetWorkerThreads() {
THREAD_COUNT.updateAndGet(i -> {
if (i == 0) {
return 0;
@@ -207,18 +207,28 @@ public class MedievalPregenMethod implements PregeneratorMethod {
}
listener.onChunkGenerating(x, z);
if (J.isFolia()) {
futures.add(PaperLib.getChunkAtAsync(world, x, z, true).thenAccept(c -> {
if (c != null) {
lastUse.put(c, M.ms());
}
listener.onChunkGenerated(x, z);
listener.onChunkCleaned(x, z);
}));
return;
}
futures.add(scheduleChunkLoad(x, z, listener));
// Single choke point for failure accounting: without onChunkFailed a lossy run can
// never satisfy allVisitsComplete, so a finished pregen reports as aborted forever.
CompletableFuture<?> chunkFuture = J.isFolia()
? PaperLib.getChunkAtAsync(world, x, z, true).thenAccept(c -> {
if (c != null) {
lastUse.put(c, M.ms());
}
listener.onChunkGenerated(x, z);
try {
listener.onChunkCleaned(x, z);
} catch (Throwable e) {
// Already counted as generated; a throw here must not also count the
// chunk failed through the whenComplete choke point below.
IrisLogging.reportError(e);
}
})
: scheduleChunkLoad(x, z, listener);
futures.add(chunkFuture.whenComplete((r, err) -> {
if (err != null) {
listener.onChunkFailed(x, z);
}
}));
}
private CompletableFuture<?> scheduleChunkLoad(int x, int z, PregenListener listener) {
@@ -294,12 +304,15 @@ public class MedievalPregenMethod implements PregeneratorMethod {
IrisLogging.reportError(error);
}
try {
generateChunkSync(x, z, listener).get(CHUNK_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
future.complete(null);
} catch (Throwable fallbackError) {
future.completeExceptionally(fallbackError);
}
// Chain instead of blocking: parking this MultiBurst worker on a 60s get()
// pinned a shared pool thread per failing chunk.
generateChunkSync(x, z, listener).whenComplete((r, fallbackError) -> {
if (fallbackError != null) {
future.completeExceptionally(fallbackError);
} else {
future.complete(null);
}
});
}
});
@@ -314,7 +327,13 @@ public class MedievalPregenMethod implements PregeneratorMethod {
Chunk chunk = world.getChunkAt(x, z);
lastUse.put(chunk, M.ms());
listener.onChunkGenerated(x, z);
listener.onChunkCleaned(x, z);
try {
listener.onChunkCleaned(x, z);
} catch (Throwable e) {
// The chunk already counted as generated; a throw here must not also count it
// as failed through the whenComplete choke point.
IrisLogging.reportError(e);
}
}
@Override
@@ -131,16 +131,28 @@ public class IrisCodeWorkspace {
project.getPath().mkdirs();
File ws = getCodeWorkspaceFile();
// Render before touching the file: a config-generation failure has nothing to do with
// the existing workspace file, and deleting it before a rethrowing rebuild silently
// destroyed the author's workspace on every boot.
String rendered;
try {
writeIfChanged(ws, createCodeWorkspaceConfig().toString(4));
rendered = createCodeWorkspaceConfig().toString(4);
} catch (Throwable e) {
IrisLogging.reportError(e);
IrisLogging.warn("Could not generate the code workspace config for " + ws.getAbsolutePath() + "; leaving the existing workspace file untouched.");
return false;
}
try {
writeIfChanged(ws, rendered);
return true;
} catch (Throwable e) {
IrisLogging.reportError(e);
IrisLogging.warn("Project invalid: " + ws.getAbsolutePath() + " Re-creating. You may loose some vs-code workspace settings! But not your actual project!");
ws.delete();
try {
IO.writeAll(ws, createCodeWorkspaceConfig());
} catch (IOException e1) {
IO.writeAll(ws, rendered);
} catch (Throwable e1) {
IrisLogging.reportError(e1);
e1.printStackTrace();
}
@@ -29,6 +29,8 @@ import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisEntity;
import art.arcane.iris.engine.object.IrisGenerator;
import art.arcane.iris.engine.object.IrisLootTable;
import art.arcane.iris.core.pack.PackExportClosure;
import art.arcane.iris.engine.object.IrisMarker;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisSpawner;
@@ -49,6 +51,7 @@ import org.zeroturnaround.zip.ZipUtil;
import java.io.File;
import java.io.IOException;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.UUID;
@@ -62,8 +65,25 @@ public class IrisPackageCompiler {
}
public File compilePackage(VolmitSender sender, boolean obfuscate, boolean minify) {
// Detached loader on purpose: obfuscation rewrites the placement 'place' lists on the
// loaded resources, which must never leak into the shared cached pack state a second
// export against a mutated cache produced archives with an empty objects/ directory.
IrisData dm = IrisData.openRuntime(project.getPath());
try {
return compilePackage(dm, sender, obfuscate, minify);
} finally {
dm.close();
}
}
private static <T> void addIfPresent(KSet<T> set, T value) {
if (value != null) {
set.add(value);
}
}
private File compilePackage(IrisData dm, VolmitSender sender, boolean obfuscate, boolean minify) {
String dimm = project.getName();
IrisData dm = IrisData.get(project.getPath());
IrisDimension dimension = dm.getDimensionLoader().load(dimm);
File folder = new File(IrisPlatforms.get().dataFolder(), "exports/" + dimension.getLoadKey());
IO.delete(folder);
@@ -82,20 +102,35 @@ public class IrisPackageCompiler {
KSet<IrisLootTable> loot = new KSet<>();
KSet<IrisBlockData> blocks = new KSet<>();
// KSet is ConcurrentHashMap-backed: both add(null) and remove(null) throw NPE, so a
// failed loader lookup must be filtered at the add site, never stripped afterwards.
for (String i : dm.getBlockLoader().getPossibleKeys()) {
blocks.add(dm.getBlockLoader().load(i));
addIfPresent(blocks, dm.getBlockLoader().load(i));
}
dimension.getRegions().forEach((i) -> regions.add(dm.getRegionLoader().load(i)));
dimension.getLoot().getTables().forEach((i) -> loot.add(dm.getLootLoader().load(i)));
dimension.getRegions().forEach((i) -> addIfPresent(regions, dm.getRegionLoader().load(i)));
dimension.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i)));
regions.forEach((i) -> biomes.addAll(i.getAllBiomes(() -> dm)));
regions.forEach((r) -> r.getLoot().getTables().forEach((i) -> loot.add(dm.getLootLoader().load(i))));
regions.forEach((r) -> r.getEntitySpawners().forEach((sp) -> spawners.add(dm.getSpawnerLoader().load(sp))));
dimension.getEntitySpawners().forEach((sp) -> spawners.add(dm.getSpawnerLoader().load(sp)));
biomes.forEach((i) -> i.getGenerators().forEach((j) -> generators.add(j.getCachedGenerator(() -> dm))));
biomes.forEach((r) -> r.getLoot().getTables().forEach((i) -> loot.add(dm.getLootLoader().load(i))));
biomes.forEach((r) -> r.getEntitySpawners().forEach((sp) -> spawners.add(dm.getSpawnerLoader().load(sp))));
collectSpawnerEntityKeys(spawners).forEach((i) -> entities.add(dm.getEntityLoader().load(i)));
regions.forEach((r) -> r.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i))));
regions.forEach((r) -> r.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp))));
dimension.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp)));
biomes.forEach((i) -> i.getGenerators().forEach((j) -> addIfPresent(generators, j.getCachedGenerator(() -> dm))));
biomes.forEach((r) -> r.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i))));
biomes.forEach((r) -> r.getEntitySpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp))));
KList<IrisObjectPlacement> allPlacements = new KList<>();
regions.forEach((r) -> allPlacements.addAll(r.getObjects()));
biomes.forEach((i) -> allPlacements.addAll(i.getObjects()));
KSet<IrisMarker> markers = new KSet<>();
for (String markerKey : PackExportClosure.collectMarkerKeys(allPlacements)) {
IrisMarker marker = dm.getMarkerLoader().load(markerKey);
if (marker == null) {
continue;
}
markers.add(marker);
marker.getSpawners().forEach((sp) -> addIfPresent(spawners, dm.getSpawnerLoader().load(sp)));
}
collectSpawnerEntityKeys(spawners).forEach((i) -> addIfPresent(entities, dm.getEntityLoader().load(i)));
entities.forEach((e) -> e.getLoot().getTables().forEach((i) -> addIfPresent(loot, dm.getLootLoader().load(i))));
Set<String> structureKeys = new LinkedHashSet<>();
collectStructureKeys(structureKeys, dimension.getStructures());
regions.forEach((region) -> collectStructureKeys(structureKeys, region.getStructures()));
@@ -106,25 +141,23 @@ public class IrisPackageCompiler {
StringBuilder c = new StringBuilder();
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_PROJECT_SERIALIZING_OBJECTS));
for (IrisBiome i : biomes) {
for (IrisObjectPlacement j : i.getObjects()) {
b.append(j.hashCode());
KList<String> newNames = new KList<>();
for (IrisObjectPlacement j : allPlacements) {
b.append(j.hashCode());
KList<String> newNames = new KList<>();
for (String k : j.getPlace()) {
if (renameObjects.containsKey(k)) {
newNames.add(renameObjects.get(k));
continue;
}
String name = !obfuscate ? k : UUID.randomUUID().toString().replaceAll("-", "");
b.append(name);
newNames.add(name);
renameObjects.put(k, name);
for (String k : j.getPlace()) {
if (renameObjects.containsKey(k)) {
newNames.add(renameObjects.get(k));
continue;
}
j.setPlace(newNames);
String name = !obfuscate ? k : UUID.randomUUID().toString().replaceAll("-", "");
b.append(name);
newNames.add(name);
renameObjects.put(k, name);
}
j.setPlace(newNames);
}
KMap<String, KList<String>> lookupObjects = renameObjects.flip();
@@ -132,10 +165,18 @@ public class IrisPackageCompiler {
ChronoLatch cl = new ChronoLatch(1000);
O<Integer> ggg = new O<>();
ggg.set(0);
biomes.forEach((i) -> i.getObjects().forEach((j) -> j.getPlace().forEach((k) ->
O<Integer> missingObjects = new O<>();
missingObjects.set(0);
allPlacements.forEach((j) -> j.getPlace().forEach((k) ->
{
try {
File f = dm.getObjectLoader().findFile(lookupObjects.get(k).get(0));
KList<String> sources = lookupObjects.get(k);
File f = sources == null || sources.isEmpty() ? null : dm.getObjectLoader().findFile(sources.get(0));
if (f == null) {
missingObjects.set(missingObjects.get() + 1);
IrisLogging.error("Missing object for placement key " + k);
return;
}
IO.copyFile(f, new File(folder, "objects/" + k + ".iob"));
gb.append(IO.hash(f));
ggg.set(ggg.get() + 1);
@@ -146,9 +187,15 @@ public class IrisPackageCompiler {
sender.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.IRIS_PROJECT_WROTE_ANOTHER_OBJECTS, MessageArgument.untrusted("g", String.valueOf(g))));
}
} catch (Throwable e) {
missingObjects.set(missingObjects.get() + 1);
IrisLogging.reportError(e);
}
})));
}));
if (missingObjects.get() > 0) {
// A package missing objects must fail loudly, not ship as a clean "compiled".
throw new IllegalStateException(missingObjects.get()
+ " object(s) could not be exported for pack '" + dimm + "'; package compile aborted.");
}
b.append(IO.hash(gb.toString()));
c.append(IO.hash(b.toString()));
@@ -205,6 +252,19 @@ public class IrisPackageCompiler {
b.append(IO.hash(a));
}
// Sorted so package.json.hash stays stable across runs.
for (IrisSpawner i : spawners.stream().sorted(Comparator.comparing(IrisSpawner::getLoadKey)).toList()) {
a = new JSONObject(new Gson().toJson(i)).toString(minify ? 0 : 4);
IO.writeAll(new File(folder, "spawners/" + i.getLoadKey() + ".json"), a);
b.append(IO.hash(a));
}
for (IrisMarker i : markers.stream().sorted(Comparator.comparing(IrisMarker::getLoadKey)).toList()) {
a = new JSONObject(new Gson().toJson(i)).toString(minify ? 0 : 4);
IO.writeAll(new File(folder, "markers/" + i.getLoadKey() + ".json"), a);
b.append(IO.hash(a));
}
c.append(IO.hash(b.toString()));
String finalHash = IO.hash(c.toString());
JSONObject meta = new JSONObject();
@@ -149,7 +149,10 @@ public final class GoldenHashEngine {
if (request.resetMantle()) {
progress.stage(IrisLanguage.plain(RuntimeProgressMessages.CHUNK_STAGE_RESETTING_MANTLE));
resetMantleFull();
if (!resetMantleFull()) {
// A partial reset silently invalidates the capture; never scan over it.
return false;
}
}
List<int[]> targets = ChunkSpiral.centerOut(request.centerChunkX(), request.centerChunkZ(), radius);
@@ -184,29 +187,24 @@ public final class GoldenHashEngine {
}
}
private void resetMantleFull() {
private boolean resetMantleFull() {
Mantle mantle = engine.getMantle().getMantle();
try {
Mantle mantle = engine.getMantle().getMantle();
mantle.saveAll();
File folder = mantle.getDataFolder();
File[] files = folder.listFiles();
if (files != null) {
for (File file : files) {
if (file.isFile()) {
file.delete();
}
}
}
// resetStorage invalidates the IOWorker channel cache before unlinking; deleting
// behind cached channels sent every post-reset plate write to an unlinked inode.
mantle.resetStorage();
feedback.ok(IrisLanguage.plain(
RuntimeProgressMessages.GOLDEN_MANTLE_RESET,
MessageArgument.untrusted("path", folder.getAbsolutePath())
MessageArgument.untrusted("path", mantle.getDataFolder().getAbsolutePath())
));
return true;
} catch (Throwable e) {
IrisLogging.reportError(e);
feedback.warn(IrisLanguage.plain(
RuntimeProgressMessages.GOLDEN_MANTLE_RESET_FAILED,
MessageArgument.untrusted("type", e.getClass().getSimpleName())
));
return false;
}
}
@@ -345,11 +343,18 @@ public final class GoldenHashEngine {
List<String> body = orderedBody(lines);
List<String> mismatches = new ArrayList<>();
// Display strings and diagnosis input are different shapes: diagnose() parses a bare
// "<x> <z>" key, and feeding it a localized sentence aborted the diagnosis whenever
// the FIRST mismatch was a chunk missing from the golden capture.
String firstMismatchKey = null;
for (String line : body) {
int second = line.indexOf(' ', line.indexOf(' ') + 1);
String key = line.substring(0, second);
String golden = goldenChunks.get(key);
if (!line.equals(golden)) {
if (firstMismatchKey == null) {
firstMismatchKey = key;
}
mismatches.add(golden == null
? IrisLanguage.plain(
RuntimeProgressMessages.GOLDEN_MISSING_IN_GOLDEN,
@@ -400,7 +405,7 @@ public final class GoldenHashEngine {
IrisLogging.info("goldenhash MISMATCH: " + mismatches.size() + "/" + body.size() + " -> " + current.getAbsolutePath());
progress.stage(IrisLanguage.plain(RuntimeProgressMessages.CHUNK_STAGE_DIAGNOSING));
diagnose(mismatches.getFirst());
diagnose(firstMismatchKey);
return false;
}
@@ -44,6 +44,8 @@ import org.bukkit.generator.ChunkGenerator.ChunkData;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
public final class InPlaceChunkRegenerator {
private static final int BIOME_STEP = 4;
@@ -111,36 +113,41 @@ public final class InPlaceChunkRegenerator {
inFlight.acquire();
MultiBurst.burst.lazy(() -> {
TerrainChunk buffer = TerrainChunk.create(world);
try {
engine.generate(chunkX << 4, chunkZ << 4, buffer, false);
} catch (Throwable e) {
IrisLogging.reportError(e);
reporter.countApplied(false);
inFlight.release();
allApplied.countDown();
return;
}
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
boolean ok = false;
try {
applyToLiveChunk(chunkX, chunkZ, buffer);
ok = true;
} catch (Throwable e) {
IrisLogging.reportError(e);
} finally {
// Settle exactly once no matter which path fails: an unguarded throw here
// (TerrainChunk.create, runRegion) parked the regen thread on the latch
// forever and stranded the player's boss bar. CAS is required on Folia's
// owned-region path runRegion runs the apply inline before returning.
AtomicBoolean settled = new AtomicBoolean();
Consumer<Boolean> settle = ok -> {
if (settled.compareAndSet(false, true)) {
reporter.countApplied(ok);
inFlight.release();
allApplied.countDown();
}
});
};
try {
TerrainChunk buffer = TerrainChunk.create(world);
engine.generate(chunkX << 4, chunkZ << 4, buffer, false);
if (!scheduled) {
IrisLogging.warn("Regen could not schedule chunk apply at " + chunkX + "," + chunkZ + " in " + world.getName() + ".");
reporter.countApplied(false);
inFlight.release();
allApplied.countDown();
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
boolean ok = false;
try {
applyToLiveChunk(chunkX, chunkZ, buffer);
ok = true;
} catch (Throwable e) {
IrisLogging.reportError(e);
} finally {
settle.accept(ok);
}
});
if (!scheduled) {
IrisLogging.warn("Regen could not schedule chunk apply at " + chunkX + "," + chunkZ + " in " + world.getName() + ".");
settle.accept(false);
}
} catch (Throwable e) {
IrisLogging.reportError(e);
settle.accept(false);
}
});
}
@@ -609,7 +609,7 @@ public final class StudioOpenCoordinator {
});
return operation.whenComplete((result, throwable) -> {
if (world != null) {
IrisToolbelt.endWorldMaintenance(world, "studio-close");
IrisToolbelt.endWorldMaintenance(world, "studio-close", true);
}
});
}
@@ -6,7 +6,6 @@ import art.arcane.iris.core.safeguard.task.Tasks;
import art.arcane.iris.core.safeguard.task.ValueWithDiagnostics;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.scheduling.J;
import java.util.ArrayList;
import java.util.Collections;
@@ -15,7 +14,6 @@ import java.util.List;
import java.util.Map;
public final class IrisSafeguard {
private static volatile boolean forceShutdown = false;
private static Map<Task, ValueWithDiagnostics<Mode>> results = Collections.emptyMap();
private static Map<String, String> context = Collections.emptyMap();
private static Map<String, List<String>> attachment = Collections.emptyMap();
@@ -100,10 +98,6 @@ public final class IrisSafeguard {
}
}
public static boolean isForceShutdown() {
return forceShutdown;
}
private static void warning() {
IrisLogging.warn(C.GOLD + "Iris is running in Warning Mode");
IrisLogging.warn(C.GRAY + "Some startup checks need attention. Review the messages above for tuning suggestions.");
@@ -115,9 +109,9 @@ public final class IrisSafeguard {
IrisLogging.error(C.DARK_RED + "Iris is running in Danger Mode");
IrisLogging.error("");
IrisLogging.error(C.DARK_GRAY + "--==<" + C.RED + " IMPORTANT " + C.DARK_GRAY + ">==--");
IrisLogging.error("Critical startup checks failed. Iris will continue startup in 10 seconds.");
IrisLogging.error("Review and resolve the errors above as soon as possible.");
J.sleep(10000L);
IrisLogging.error("Critical startup checks failed. Review and resolve the errors above as soon as possible.");
// No startup sleep: blocking the boot thread protected nothing world creation and
// player admission are already gated by IrisStartupValidation.
IrisLogging.info("");
}
}
@@ -7,6 +7,7 @@ import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.nms.v1X.NMSBinding1X;
import art.arcane.iris.core.safeguard.Mode;
import art.arcane.iris.core.splash.IrisSplashComposer;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.util.common.misc.getHardware;
import art.arcane.iris.util.project.agent.Agent;
@@ -99,13 +100,21 @@ public final class Tasks {
supportedVersions = BuildConstants.MINECRAFT_VERSION;
}
if (!INMS.isBound()) {
String cause = INMS.bindFailure() == null ? "Unknown NMS bind failure" : INMS.bindFailure().getMessage();
return withDiagnostics(Mode.UNSTABLE,
Diagnostic.Logger.ERROR.create("Server Version"),
Diagnostic.Logger.ERROR.create("- " + cause),
Diagnostic.Logger.ERROR.create("- Iris only supports " + supportedVersions));
}
if (!(INMS.get() instanceof NMSBinding1X)) {
return withDiagnostics(Mode.STABLE);
}
return withDiagnostics(Mode.UNSTABLE,
Diagnostic.Logger.ERROR.create("Server Version"),
Diagnostic.Logger.ERROR.create("- Iris only supports " + supportedVersions));
Diagnostic.Logger.ERROR.create("NMS Disabled"),
Diagnostic.Logger.ERROR.create("- NMS support is disabled (general.disableNMS); Iris world creation is unavailable."));
});
private static final Task INJECTION = Task.of("injection", () -> {
@@ -149,7 +158,13 @@ public final class Tasks {
});
private static final Task JAVA = Task.of("java", () -> {
int version = javaVersion();
int version = IrisSplashComposer.javaVersion();
if (version < 0) {
return withDiagnostics(Mode.WARNING,
Diagnostic.Logger.WARN.create("Java Runtime"),
Diagnostic.Logger.WARN.create("- Java version could not be determined (java.version="
+ System.getProperty("java.version") + ")."));
}
if (version == 25) {
return withDiagnostics(Mode.STABLE);
}
@@ -211,16 +226,4 @@ public final class Tasks {
return new ValueWithDiagnostics<>(mode, diagnostics);
}
private static int javaVersion() {
String version = System.getProperty("java.version");
if (version.startsWith("1.")) {
version = version.substring(2, 3);
} else {
int dot = version.indexOf(".");
if (dot != -1) {
version = version.substring(0, dot);
}
}
return Integer.parseInt(version);
}
}
@@ -67,7 +67,8 @@ public class ExternalDataSVC implements IrisService {
@Override
public void onEnable() {
IrisLogging.info("Loading ExternalDataProvider...");
Bukkit.getPluginManager().registerEvents(this, BukkitPlatform.plugin());
// enable() registers every enabled service as a listener; self-registration here
// doubled every handler invocation.
for (ProviderDefinition definition : BUILT_IN_PROVIDERS) {
activateConfiguredProvider(definition);
@@ -1,6 +1,5 @@
package art.arcane.iris.core.service;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.util.common.plugin.IrisService;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
@@ -10,17 +9,26 @@ import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.Logger;
import org.apache.logging.log4j.message.Message;
import java.util.List;
public class LogFilterSVC implements IrisService, Filter {
private static final String HEIGHTMAP_MISMATCH = "Ignoring heightmap data for chunk";
private static final String RAID_PERSISTENCE = "Could not save data net.minecraft.world.entity.raid.PersistentRaid";
private static final String DUPLICATE_ENTITY_UUID = "UUID of added entity already exists";
private static final KList<String> FILTERS = new KList<>();
// Immutable: check() runs on arbitrary logger threads, and the root logger is JVM-global,
// so a mutable static here was both a CME hazard and grew by three entries per enable.
private static final List<String> FILTERS = List.of(HEIGHTMAP_MISMATCH, RAID_PERSISTENCE, DUPLICATE_ENTITY_UUID);
private boolean installed = false;
public void onEnable() {
FILTERS.add(HEIGHTMAP_MISMATCH, RAID_PERSISTENCE, DUPLICATE_ENTITY_UUID);
if (installed) {
return;
}
((Logger) LogManager.getRootLogger()).addFilter(this);
installed = true;
}
public void initialize() {
@@ -33,6 +41,11 @@ public class LogFilterSVC implements IrisService, Filter {
}
public void onDisable() {
if (!installed) {
return;
}
((Logger) LogManager.getRootLogger()).get().removeFilter(this);
installed = false;
}
public boolean isStarted() {
@@ -116,8 +129,14 @@ public class LogFilterSVC implements IrisService, Filter {
}
private Result check(String string) {
if (FILTERS.stream().anyMatch(string::contains))
return Result.DENY;
if (string == null) {
return Result.NEUTRAL;
}
for (String filter : FILTERS) {
if (string.contains(filter)) {
return Result.DENY;
}
}
return Result.NEUTRAL;
}
}
@@ -24,15 +24,17 @@ import lombok.Getter;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.Iterator;
import java.util.Map;
public class ObjectSVC implements IrisService {
@Getter
private final Deque<Map<Block, BlockData>> undos = new ArrayDeque<>();
// Concurrent + global-thread pipeline: pastes publish via J.runGlobal while the undo
// command arrives on an async pool thread; a plain ArrayDeque was mutated from both.
private final Deque<Map<Block, BlockData>> undos = new ConcurrentLinkedDeque<>();
@Override
@@ -50,7 +52,9 @@ public class ObjectSVC implements IrisService {
}
public void revertChanges(int amount) {
loopChange(amount);
if (!J.runGlobal(() -> loopChange(amount))) {
loopChange(amount);
}
}
private void loopChange(int amount) {
@@ -68,22 +72,26 @@ public class ObjectSVC implements IrisService {
* @param blocks The blocks to remove
*/
private void revert(Map<Block, BlockData> blocks) {
Iterator<Map.Entry<Block, BlockData>> it = blocks.entrySet().iterator();
if (blocks == null || blocks.isEmpty()) {
return;
}
J.s(() -> {
Iterator<Map.Entry<Block, BlockData>> it = blocks.entrySet().iterator();
int amount = 0;
while (it.hasNext()) {
Map.Entry<Block, BlockData> entry = it.next();
BlockData data = entry.getValue();
entry.getKey().setBlockData(data, false);
entry.getKey().setBlockData(entry.getValue(), false);
it.remove();
amount++;
if (amount > 200) {
J.s(() -> revert(blocks), 1);
if (++amount >= 200) {
break;
}
}
if (!blocks.isEmpty()) {
J.s(() -> revert(blocks), 1);
}
});
}
}
@@ -219,7 +219,10 @@ public class StudioSVC implements IrisService {
validatePublishedPack(target);
IrisData installedData;
boolean activeRuntime = previousData != null && !previousData.getEngines().isEmpty();
// Live engines only ever attach to detached openRuntime loaders, never to the
// dataLoaders-cached previousData so the old previousData.getEngines() test was
// always empty and a running world's pack could be swapped with no restart.
boolean activeRuntime = IrisData.hasActiveEngines(target.toFile());
if (previousData == null) {
createdData = IrisData.get(target.toFile());
installedData = createdData;
@@ -780,7 +783,7 @@ public class StudioSVC implements IrisService {
return generator.closeAsync().thenApply(ignored -> true);
})
.whenComplete((unloaded, throwable) -> {
IrisToolbelt.endWorldMaintenance(world, "studio-disable");
IrisToolbelt.endWorldMaintenance(world, "studio-disable", true);
if (throwable != null) {
IrisLogging.reportError("Failed to unload studio world \"" + world.getName()
+ "\" during disable cleanup; startup deletion remains queued.", throwable);
@@ -790,7 +793,7 @@ public class StudioSVC implements IrisService {
}
});
} catch (Throwable e) {
IrisToolbelt.endWorldMaintenance(world, "studio-disable");
IrisToolbelt.endWorldMaintenance(world, "studio-disable", true);
IrisLogging.reportError("Failed to unload studio world \"" + world.getName() + "\" during shutdown cleanup.", e);
}
}
@@ -64,7 +64,10 @@ import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Predicate;
public class TreeSVC implements IrisService {
private boolean block = false;
// Re-entrancy guard for the synthetic StructureGrowEvent this service fires from inside
// its own handler. callEvent dispatches inline on the calling thread, so a ThreadLocal
// is exact; a shared boolean let one region's growth suppress another's on Folia.
private static final ThreadLocal<Boolean> REENTRANT = ThreadLocal.withInitial(() -> false);
@Override
public void onEnable() {
@@ -88,7 +91,7 @@ public class TreeSVC implements IrisService {
*/
@EventHandler(priority = EventPriority.HIGHEST)
public void on(StructureGrowEvent event) {
if (block || event.isCancelled()) {
if (REENTRANT.get() || event.isCancelled()) {
return;
}
IrisLogging.debug(this.getClass().getName() + " received a structure grow event");
@@ -161,11 +164,13 @@ public class TreeSVC implements IrisService {
public void set(int x, int y, int z, PlatformBlockState s) {
BlockData d = (BlockData) s.nativeHandle();
Block b = event.getWorld().getBlockAt(x, y, z);
// Listeners get the post-growth tree, per the StructureGrowEvent contract;
// a fresh snapshot here handed every plugin an all-air "tree".
BlockState state = b.getState();
if (d instanceof IrisCustomData data)
state.setBlockData(data.getBase());
else state.setBlockData(d);
blockStateList.add(b.getState());
blockStateList.add(state);
dataCache.put(new Location(event.getWorld(), x, y, z), d);
}
@@ -245,9 +250,12 @@ public class TreeSVC implements IrisService {
Runnable growTask = () -> {
StructureGrowEvent iGrow = new StructureGrowEvent(event.getLocation(), event.getSpecies(), event.isFromBonemeal(), event.getPlayer(), blockStateList);
block = true;
Bukkit.getServer().getPluginManager().callEvent(iGrow);
block = false;
REENTRANT.set(true);
try {
Bukkit.getServer().getPluginManager().callEvent(iGrow);
} finally {
REENTRANT.set(false);
}
if (!iGrow.isCancelled()) {
for (BlockState state : iGrow.getBlocks()) {
@@ -338,6 +346,11 @@ public class TreeSVC implements IrisService {
public Cuboid getSaplings(Location at, Predicate<BlockData> valid, World world) {
KList<BlockPosition> blockPositions = new KList<>();
grow(at.getWorld(), new BlockPosition(at.getBlockX(), at.getBlockY(), at.getBlockZ()), valid, blockPositions);
if (blockPositions.isEmpty()) {
// No matching saplings (e.g. a giant-mushroom grow event): a 1x1 plane at the
// event location, never the MIN/MAX sentinel cuboid below.
return new Cuboid(at, at);
}
BlockPosition a = new BlockPosition(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE);
BlockPosition b = new BlockPosition(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE);
@@ -24,7 +24,7 @@ public final class IrisSplashComposer {
prefix + style.label(" By: ") + style.value("Volmit Software (Arcane Arts)"),
prefix + style.label(" Web: ") + style.value("VolmitSoftware.com"),
prefix + style.label(" Server: ") + style.value(serverLine),
prefix + style.label(" Java: ") + style.value(String.valueOf(javaVersion())) + style.label(" | Date: ") + style.value(startupDate()),
prefix + style.label(" Java: ") + style.value(javaVersion() < 0 ? "unknown" : String.valueOf(javaVersion())) + style.label(" | Date: ") + style.value(startupDate()),
prefix + style.label(" Commit: ") + style.value(BuildConstants.COMMIT) + style.label("/") + style.value(BuildConstants.ENVIRONMENT),
"",
"",
@@ -54,17 +54,22 @@ public final class IrisSplashComposer {
return lines;
}
/**
* Total parser: java.version values like "25-ea" or "26-internal" have no dot but a
* non-numeric suffix, and a cosmetic banner must never be able to abort startup.
*
* @return the feature version, or -1 when it cannot be determined
*/
public static int javaVersion() {
String version = System.getProperty("java.version");
String version = System.getProperty("java.version", "");
if (version.startsWith("1.")) {
version = version.substring(2, 3);
} else {
int dot = version.indexOf('.');
if (dot != -1) {
version = version.substring(0, dot);
}
version = version.length() > 2 ? version.substring(2, 3) : "";
}
return Integer.parseInt(version);
int end = 0;
while (end < version.length() && Character.isDigit(version.charAt(end))) {
end++;
}
return end == 0 ? -1 : Integer.parseInt(version, 0, end, 10);
}
public static String releaseTrain(String version) {
@@ -90,6 +90,9 @@ public class IrisConverter {
HudSlotClaim barClaim = reportProgress
? BukkitPlatform.hudSlots().open(sender.player(), new HudSlotRequest("iris:job", HudPriority.PROGRESS, 1200L, List.of(HudSurface.ACTION_BAR, HudSurface.BOSS_BAR)))
: null;
// try/finally over the whole decode: a throw must never leak the
// self-rescheduling progress task or the HUD claims.
try {
if (mv > 2_000_000) {
largeObject = true;
IrisLogging.info(C.GRAY + "Converting.. " + schem.getName() + " -> " + schem.getName().replace(".schem", ".iob"));
@@ -149,13 +152,15 @@ public class IrisConverter {
}
}
if (i != -1) J.car(i);
if (titleClaim != null) {
titleClaim.release();
}
if (barClaim != null) {
barClaim.release();
BukkitPlatform.hudLanes().hide(sender.player(), "iris:job");
} finally {
if (i != -1) J.car(i);
if (titleClaim != null) {
titleClaim.release();
}
if (barClaim != null) {
barClaim.release();
BukkitPlatform.hudLanes().hide(sender.player(), "iris:job");
}
}
try {
object.shrinkwrap();
@@ -207,6 +207,7 @@ public class IrisCreator {
World world = null;
boolean bukkitRegistered = false;
PlatformChunkGenerator stagedGenerator = null;
try {
reportStudioProgress(0.08D, "resolve_dimension");
reportStudioProgress(0.16D, "prepare_world_pack");
@@ -252,6 +253,7 @@ public class IrisCreator {
if (access == null) {
throw new IrisException("Access is null. Something bad happened.");
}
stagedGenerator = access;
HudSlotClaim createClaim = !benchmark && studioProgressConsumer == null && sender.isPlayer()
? openLoaderClaim("iris:world-create")
: null;
@@ -332,7 +334,7 @@ public class IrisCreator {
}
return world;
} catch (Throwable failure) {
rollbackWorldCreation(worldKey, world, dimensionRoot, bukkitRegistered, failure);
rollbackWorldCreation(worldKey, world, stagedGenerator, dimensionRoot, bukkitRegistered, failure);
if (failure instanceof IrisException irisException) {
throw irisException;
}
@@ -655,12 +657,23 @@ public class IrisCreator {
private void rollbackWorldCreation(
NamespacedKey worldKey,
World createdWorld,
PlatformChunkGenerator stagedGenerator,
File dimensionRoot,
boolean bukkitRegistered,
Throwable failure
) {
World activeWorld = createdWorld == null ? WorldIdentity.resolve(worldKey).orElse(null) : createdWorld;
boolean safeToDelete = activeWorld != null || !containsTimeout(failure);
if (activeWorld == null && stagedGenerator != null) {
// The world never materialized, so no unload path will ever close the staged
// generator; without this it stays registered on WorldInitEvent and attaches a
// second engine when a same-name world is created later.
try {
stagedGenerator.closeAsync().get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (Throwable rollbackFailure) {
failure.addSuppressed(unwrapFailure(rollbackFailure));
}
}
if (activeWorld != null) {
IrisToolbelt.beginWorldMaintenance(activeWorld, "world-create-rollback", true);
try {
@@ -682,6 +695,11 @@ public class IrisCreator {
if (safeToDelete && generator != null) {
generator.closeAsync().get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
// A staged generator the world never bound (e.g. the creation failed after a
// retry re-staged a fresh one) has no unload path either.
if (stagedGenerator != null && stagedGenerator != generator) {
stagedGenerator.closeAsync().get(ROLLBACK_PHASE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
} catch (Throwable rollbackFailure) {
Throwable cause = unwrapFailure(rollbackFailure);
failure.addSuppressed(cause);
@@ -690,7 +708,7 @@ public class IrisCreator {
ServerConfigurator.restart("World creation rollback timed out for \"" + name + "\".");
}
} finally {
IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback");
IrisToolbelt.endWorldMaintenance(activeWorld, "world-create-rollback", true);
}
}
@@ -27,15 +27,18 @@ public class IrisReflectiveAPI {
WorldMaintenance.retainMantleDataForSlice(classname);
}
// These delegate to IrisToolbelt so the caller's WORLD Y is rebased into mantle space
// (0..worldHeight). Raw pass-through silently no-opped below Y=0 and read the wrong
// cell everywhere else, diverging from IrisToolbelt and IrisModdedAPI semantics.
public static void setMantleData(World world, int x, int y, int z, Object data) {
IrisToolbelt.access(world).getEngine().getMantle().getMantle().set(x, y, z, data);
IrisToolbelt.setMantleData(world, x, y, z, data);
}
public static void deleteMantleData(World world, int x, int y, int z, Class c) {
IrisToolbelt.access(world).getEngine().getMantle().getMantle().remove(x, y, z, c);
IrisToolbelt.deleteMantleData(world, x, y, z, c);
}
public static Object getMantleData(World world, int x, int y, int z, Class c) {
return IrisToolbelt.access(world).getEngine().getMantle().getMantle().get(x, y, z, c);
return IrisToolbelt.getMantleData(world, x, y, z, c);
}
}
@@ -90,7 +90,7 @@ public class IrisToolbelt {
return null;
}
File packsFolder = IrisPlatforms.get().dataFolder("packs");
File packsFolder = IrisPlatforms.get().packsFolder();
File pack = PackDirectoryResolver.resolveExisting(packsFolder, reference.pack());
if (pack == null) {
File found = findCaseInsensitivePack(packsFolder, reference.pack());
@@ -302,6 +302,13 @@ public class IrisToolbelt {
* @return the pregenerator job (already started)
*/
public static PregeneratorJob pregenerate(PregenTask task, PregeneratorMethod method, Engine engine, boolean cached) {
// Match the modded adapter's contract: a running job is a rejection, never a silent
// kill whose teardown (60s drain + 120s flush) overlaps the new job's generation.
// Callers that genuinely want replacement must stop the old job first
// (PregeneratorJob.shutdownAndWait).
if (PregeneratorJob.getInstance() != null) {
throw new IllegalStateException("An Iris pregeneration job is already running; stop it first with /iris pregen stop.");
}
applyPregenPerformanceProfile(engine);
boolean useCachedWrapper = false;
if (cached && engine != null) {
@@ -588,17 +595,25 @@ public class IrisToolbelt {
}
public static void endWorldMaintenance(World world, String reason) {
endWorldMaintenance(world, reason, false);
}
public static void endWorldMaintenance(World world, String reason, boolean bypassMantleStages) {
if (world == null) {
return;
}
endWorldMaintenance(WorldIdentity.serialize(world), reason);
endWorldMaintenance(WorldIdentity.serialize(world), reason, bypassMantleStages);
}
public static void endWorldMaintenance(String worldName, String reason) {
WorldMaintenance.endWorldMaintenance(worldName, reason);
}
public static void endWorldMaintenance(String worldName, String reason, boolean bypassMantleStages) {
WorldMaintenance.endWorldMaintenance(worldName, reason, bypassMantleStages);
}
public static boolean isWorldMaintenanceActive(World world) {
return world != null && isWorldMaintenanceActive(WorldIdentity.serialize(world));
}
@@ -639,6 +654,14 @@ public class IrisToolbelt {
e.getEngine().getMantle().getMantle().remove(x, y - world.getMinHeight(), z, of);
}
public static <T> void setMantleData(World world, int x, int y, int z, T data) {
PlatformChunkGenerator e = access(world);
if (e == null || data == null) {
return;
}
e.getEngine().getMantle().getMantle().set(x, y - world.getMinHeight(), z, data);
}
public static boolean removeWorld(World world) throws IOException {
return IrisCreator.removeFromBukkitYml(IrisWorldStorage.logicalName(world));
}
@@ -1,17 +1,16 @@
package art.arcane.iris.core.tools;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.engine.mantle.MantleSliceRetention;
import art.arcane.iris.spi.IrisLogging;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public final class WorldMaintenance {
private static final Map<String, AtomicInteger> worldMaintenanceDepth = new ConcurrentHashMap<>();
private static final Map<String, AtomicInteger> worldMaintenanceMantleBypassDepth = new ConcurrentHashMap<>();
private static final Set<String> retainedMantleSlices = ConcurrentHashMap.newKeySet();
private WorldMaintenance() {
}
@@ -37,6 +36,10 @@ public final class WorldMaintenance {
}
public static void endWorldMaintenance(String worldName, String reason) {
endWorldMaintenance(worldName, reason, false);
}
public static void endWorldMaintenance(String worldName, String reason, boolean bypassMantleStages) {
if (worldName == null) {
return;
}
@@ -52,13 +55,17 @@ public final class WorldMaintenance {
depth = 0;
}
AtomicInteger bypassCounter = worldMaintenanceMantleBypassDepth.get(worldName);
// Only a bypass-begin's paired end releases bypass credit: a plain end overlapping a
// bypassing operation stole its credit and re-enabled mantle stages under it.
int bypassDepth = 0;
if (bypassCounter != null) {
bypassDepth = bypassCounter.decrementAndGet();
if (bypassDepth <= 0) {
worldMaintenanceMantleBypassDepth.remove(worldName, bypassCounter);
bypassDepth = 0;
if (bypassMantleStages) {
AtomicInteger bypassCounter = worldMaintenanceMantleBypassDepth.get(worldName);
if (bypassCounter != null) {
bypassDepth = bypassCounter.decrementAndGet();
if (bypassDepth <= 0) {
worldMaintenanceMantleBypassDepth.remove(worldName, bypassCounter);
bypassDepth = 0;
}
}
}
@@ -88,14 +95,10 @@ public final class WorldMaintenance {
}
public static void retainMantleDataForSlice(String className) {
if (className == null) {
return;
}
retainedMantleSlices.add(className);
MantleSliceRetention.retain(className);
}
public static boolean isRetainingMantleDataForSlice(String className) {
return className != null && retainedMantleSlices.contains(className);
return MantleSliceRetention.isRetained(className);
}
}
@@ -18,10 +18,14 @@
package art.arcane.iris.engine;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
@@ -46,8 +50,10 @@ final class EngineBackgroundTasks {
boolean scheduleTrackedTask(Runnable task) {
synchronized (backgroundTaskLock) {
backgroundTasks.removeIf(tracked -> tracked.completion.isDone()
&& !tracked.completion.isCompletedExceptionally());
// A finished task is not outstanding work, however it finished. Retaining failed
// entries made the NEXT transition's drain re-report a long-settled failure and
// blocked close() from ever marking the engine closed.
backgroundTasks.removeIf(tracked -> tracked.completion.isDone());
if (!backgroundTaskAdmission) {
return false;
}
@@ -60,6 +66,10 @@ final class EngineBackgroundTasks {
return null;
} catch (Throwable exception) {
tracked.completion.completeExceptionally(exception);
// J.a(Callable) routes through a future nobody reads; report here or the
// failure is invisible.
IrisLogging.reportError(exception);
IrisLogging.error("Iris background task failed.");
throw propagate(exception);
}
});
@@ -79,6 +89,15 @@ final class EngineBackgroundTasks {
}
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(BACKGROUND_TASK_TIMEOUT_MILLIS);
Throwable failure = null;
// Settled-set snapshot taken ONCE at drain entry: a per-iteration isDone() sample
// would also suppress tasks that failed while the drain was blocked on an earlier
// task, letting a real in-flight failure pass the transition unreported.
Set<TrackedBackgroundTask> settledAtEntry = Collections.newSetFromMap(new IdentityHashMap<>());
for (TrackedBackgroundTask task : tasks) {
if (task.completion.isDone()) {
settledAtEntry.add(task);
}
}
for (TrackedBackgroundTask task : tasks) {
long remaining = deadline - System.nanoTime();
if (remaining <= 0L) {
@@ -86,6 +105,7 @@ final class EngineBackgroundTasks {
failure = appendFailure(failure, new TimeoutException("Timed out waiting for Iris background tasks during " + reason + "."));
continue;
}
boolean alreadyDone = settledAtEntry.contains(task);
try {
task.completion.get(remaining, TimeUnit.NANOSECONDS);
} catch (InterruptedException e) {
@@ -94,7 +114,13 @@ final class EngineBackgroundTasks {
failure = appendFailure(failure, e);
} catch (ExecutionException | TimeoutException e) {
cancelBackgroundTask(task, reason);
failure = appendFailure(failure, e);
// A task that had settled before this drain began was already reported when it
// failed; only genuinely in-flight failures may poison this transition.
if (!alreadyDone) {
failure = appendFailure(failure, e);
} else {
IrisLogging.debug("Ignoring pre-settled background task failure during " + reason + ".");
}
}
}
boolean complete;
@@ -33,6 +33,8 @@ import art.arcane.iris.spi.IrisServices;
* having succeeded so a partially released engine never reports itself as closed.
*/
final class EngineShutdownSequence {
private static final long CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS = 5000L;
private final IrisEngine engine;
private boolean runtimeReleased;
private boolean targetReleased;
@@ -55,11 +57,50 @@ final class EngineShutdownSequence {
engine.getClosing().set(true);
engine.backgroundTasks.closeBackgroundTaskAdmission();
EngineTickRegistry.unregisterTicking(engine);
engine.getPlatformHooks().shutdownPregenerator(engine);
// Best-effort like every other step: a pregen join timeout must not escape the
// synchronized block before anything is saved or released. On failure, save what
// can be saved and leave the close incomplete-but-retryable (a live pregen writer
// may still hold the mantle, so the releases are skipped).
Throwable pregenFailure = runCleanup(null, () -> engine.getPlatformHooks().shutdownPregenerator(engine));
if (pregenFailure != null) {
engine.lifecycleState = LifecycleState.FAILED;
failure = pregenFailure;
failure = runCleanup(failure, engine::savePrefetchOnce);
failure = runCleanup(failure, engine::saveEngineData);
failure = runCleanup(failure, () -> engine.getMantle().saveAllNow());
reportIncompleteClose(failure);
return;
}
Throwable drainFailure = null;
try {
engine.getGenerationSessions().sealAndAwait("close", IrisEngine.SESSION_DRAIN_TIMEOUT_MILLIS, true);
} catch (GenerationSessionException e) {
throw new IllegalStateException("Failed to drain Iris generation for close.", e);
drainFailure = e;
}
if (drainFailure != null) {
// A drain timeout must not abandon teardown: the world manager is the lease
// producer, so stop it first, then re-drain briefly.
IrisLogging.warn("Iris generation did not drain for close on " + engine.getWorld().name()
+ "; stopping the world manager and retrying.");
Throwable managerFailure = engine.runtime == null
? null
: runCleanup(null, engine.runtime.worldManager()::close);
try {
engine.getGenerationSessions().sealAndAwait("close-retry", CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS, true);
drainFailure = null;
} catch (GenerationSessionException e) {
drainFailure = appendFailure(drainFailure, e);
}
drainFailure = appendFailure(drainFailure, managerFailure);
}
if (drainFailure != null) {
// A live lease may be mid-write, so the mantle must not be closed at it but
// dirty plates can still be flushed (saveAll is synchronized) so terrain since
// the last periodic save is not lost. The close stays incomplete and retryable.
engine.lifecycleState = LifecycleState.FAILED;
failure = runCleanup(drainFailure, () -> engine.getMantle().saveAllNow());
reportIncompleteClose(failure);
return;
}
BackgroundTaskDrain backgroundDrain = engine.backgroundTasks.drainBackgroundTasks("close");
@@ -108,13 +149,17 @@ final class EngineShutdownSequence {
}
}
if (failure != null) {
IrisLogging.error("Iris engine shutdown remains incomplete after cleanup failures for " + engine.getWorld().name() + ".");
IrisLogging.reportError(failure);
failure.printStackTrace();
throw new IllegalStateException("Iris engine shutdown remains incomplete after cleanup failures.", failure);
reportIncompleteClose(failure);
}
}
private void reportIncompleteClose(Throwable failure) {
IrisLogging.error("Iris engine shutdown remains incomplete after cleanup failures for " + engine.getWorld().name() + ".");
IrisLogging.reportError(failure);
failure.printStackTrace();
throw new IllegalStateException("Iris engine shutdown remains incomplete after cleanup failures.", failure);
}
void cleanupFailedConstruction(Throwable original) {
engine.getClosing().set(true);
engine.backgroundTasks.closeBackgroundTaskAdmission();
@@ -118,7 +118,11 @@ public class IrisComplex implements DataProvider {
private IrisRegion focusRegion;
private Map<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> generatorBounds;
private Set<IrisBiome> generatorBiomes;
private final Map<IrisBiome, ChildSelectionPlan> childSelectionPlans = Collections.synchronizedMap(new IdentityHashMap<>());
// Copy-on-write: reads happen per column on every burst thread; the synchronizedMap
// monitor was taken on every HIT. Writes are once per biome and bounded, so a fresh map
// per insert is cheap. Identity keying is load-bearing (IrisBiome is mutable/value-hashed).
private volatile IdentityHashMap<IrisBiome, ChildSelectionPlan> childSelectionPlans = new IdentityHashMap<>();
private final Object childSelectionPlanLock = new Object();
public IrisComplex(Engine engine) {
this(engine, false);
@@ -169,7 +173,7 @@ public class IrisComplex implements DataProvider {
generatorBounds = buildGeneratorBounds(engine);
KList<IrisShapedGeneratorStyle> overlayNoise = engine.getDimension().getOverlayNoise();
overlayStream = overlayNoise.isEmpty()
? ProceduralStream.ofDouble((x, z) -> 0.0D).waste("Overlay Stream")
? ProceduralStream.ofDouble((x, z) -> 0.0D)
: ProceduralStream.ofDouble((x, z) -> {
double value = 0D;
@@ -178,22 +182,22 @@ public class IrisComplex implements DataProvider {
}
return value;
}).waste("Overlay Stream");
});
rockStream = engine.getDimension().getRockPalette().getLayerGenerator(rng.nextParallelRNG(45), data).stream()
.select(engine.getDimension().getRockPalette().getBlockData(data)).waste("Rock Stream");
.select(engine.getDimension().getRockPalette().getBlockData(data));
fluidStream = engine.getDimension().getFluidPalette().getLayerGenerator(rng.nextParallelRNG(78), data).stream()
.select(engine.getDimension().getFluidPalette().getBlockData(data)).waste("Fluid Stream");
.select(engine.getDimension().getFluidPalette().getBlockData(data));
regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream()
.zoom(engine.getDimension().getRegionZoom()).waste("Region Style");
regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE).waste("Region Identity Stream");
.zoom(engine.getDimension().getRegionZoom());
regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE);
regionStream = focusRegion != null ?
ProceduralStream.of((x, z) -> focusRegion,
Interpolated.of(a -> 0D, a -> focusRegion))
: regionStyleStream
.selectRarity(data.getRegionLoader().loadAll(engine.getDimension().getRegions()))
.cache2D("regionStream", engine, cacheSize).waste("Region Stream");
.cache2D("regionStream", engine, cacheSize);
regionIDStream = regionIdentityStream.convertCached((i) -> new UUID(Double.doubleToLongBits(i),
String.valueOf(i * 38445).hashCode() * 3245556666L)).waste("Region ID Stream");
String.valueOf(i * 38445).hashCode() * 3245556666L));
caveBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r)
-> engine.getDimension().getCaveBiomeStyle().create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream()
@@ -201,7 +205,7 @@ public class IrisComplex implements DataProvider {
.zoom(r.getCaveBiomeZoom())
.selectRarity(loadInferredBiomes(r.getCaveBiomes(), InferredType.CAVE))
.onNull(emptyBiome)
).convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize).waste("Cave Biome Stream");
).convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.CAVE, caveBiomeStream);
landBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r)
@@ -211,7 +215,7 @@ public class IrisComplex implements DataProvider {
.zoom(r.getLandBiomeZoom())
.selectRarity(loadInferredBiomes(r.getLandBiomes(), InferredType.LAND))
).convertAware2D(ProceduralStream::get)
.cache2D("landBiomeStream", engine, cacheSize).waste("Land Biome Stream");
.cache2D("landBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.LAND, landBiomeStream);
seaBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r)
@@ -221,7 +225,7 @@ public class IrisComplex implements DataProvider {
.zoom(r.getSeaBiomeZoom())
.selectRarity(loadInferredBiomes(r.getSeaBiomes(), InferredType.SEA))
).convertAware2D(ProceduralStream::get)
.cache2D("seaBiomeStream", engine, cacheSize).waste("Sea Biome Stream");
.cache2D("seaBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.SEA, seaBiomeStream);
shoreBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r)
@@ -229,60 +233,60 @@ public class IrisComplex implements DataProvider {
.zoom(engine.getDimension().getBiomeZoom())
.zoom(r.getShoreBiomeZoom())
.selectRarity(loadInferredBiomes(r.getShoreBiomes(), InferredType.SHORE))
).convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize).waste("Shore Biome Stream");
).convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.SHORE, shoreBiomeStream);
bridgeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome.getInferredType(),
Interpolated.of(a -> 0D, a -> focusBiome.getInferredType())) :
engine.getDimension().getContinentalStyle().create(rng.nextParallelRNG(234234565), getData())
.bake().scale(1D / engine.getDimension().getContinentZoom()).bake().stream()
.convert((v) -> v >= engine.getDimension().getLandChance() ? InferredType.SEA : InferredType.LAND)
.cache2D("bridgeStream", engine, cacheSize).waste("Bridge Stream");
.cache2D("bridgeStream", engine, cacheSize);
baseBiomeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome,
Interpolated.of(a -> 0D, a -> focusBiome)) :
bridgeStream.convertAware2D((t, x, z) -> inferredStreams.get(t).get(x, z))
.convertAware2D(this::implode)
.cache2D("baseBiomeStream", engine, cacheSize).waste("Base Biome Stream");
.cache2D("baseBiomeStream", engine, cacheSize);
heightStream = ProceduralStream.of((x, z) -> {
IrisBiome b = focusBiome != null ? focusBiome : baseBiomeStream.get(x, z);
return getHeight(engine, b, x, z, engine.getSeedManager().getHeight());
}, Interpolated.DOUBLE).cache2DDouble("heightStream", engine, cacheSize).waste("Height Stream");
}, Interpolated.DOUBLE).cache2DDouble("heightStream", engine, cacheSize);
roundedHeighteightStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.round().waste("Rounded Height Stream");
.round();
slopeStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.slope(3).cache2DDouble("slopeStream", engine, cacheSize).waste("Slope Stream");
.slope(3).cache2DDouble("slopeStream", engine, cacheSize);
trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
b -> focusBiome))
.cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream
.convertAware2D((h, x, z) ->
fixBiomeType(h, baseBiomeStream.get(x, z),
regionStream.contextInjecting(engine, (c, xx, zz) -> c.getRegion().get(xx, zz)).get(x, z), x, z, fluidHeight))
.cache2D("trueBiomeStream", engine, cacheSize).waste("True Biome Stream");
.cache2D("trueBiomeStream", engine, cacheSize);
trueBiomeDerivativeStream = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())).cache2D("trueBiomeDerivativeStream", engine, cacheSize).waste("True Biome Derivative Stream");
.convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())).cache2D("trueBiomeDerivativeStream", engine, cacheSize);
heightFluidStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.max(fluidHeight).cache2DDouble("heightFluidStream", engine, cacheSize).waste("Height Fluid Stream");
maxHeightStream = ProceduralStream.ofDouble((x, z) -> height).waste("Max Height Stream");
.max(fluidHeight).cache2DDouble("heightFluidStream", engine, cacheSize);
maxHeightStream = ProceduralStream.ofDouble((x, z) -> height);
terrainSurfaceDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainSurfaceDecoration", engine, cacheSize).waste("Surface Decoration Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainSurfaceDecoration", engine, cacheSize);
terrainCeilingDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)).cache2D("terrainCeilingDecoration", engine, cacheSize).waste("Ceiling Decoration Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)).cache2D("terrainCeilingDecoration", engine, cacheSize);
terrainCaveSurfaceDecoration = caveBiomeStream.contextInjecting(engine, (c, x, z) -> c.getCave().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainCaveSurfaceDecoration", engine, cacheSize).waste("Cave Surface Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainCaveSurfaceDecoration", engine, cacheSize);
terrainCaveCeilingDecoration = caveBiomeStream.contextInjecting(engine, (c, x, z) -> c.getCave().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)).cache2D("terrainCaveCeilingDecoration", engine, cacheSize).waste("Cave Ceiling Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.CEILING)).cache2D("terrainCaveCeilingDecoration", engine, cacheSize);
shoreSurfaceDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SHORE_LINE)).cache2D("shoreSurfaceDecoration", engine, cacheSize).waste("Shore Surface Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SHORE_LINE)).cache2D("shoreSurfaceDecoration", engine, cacheSize);
seaSurfaceDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_SURFACE)).cache2D("seaSurfaceDecoration", engine, cacheSize).waste("Sea Surface Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_SURFACE)).cache2D("seaSurfaceDecoration", engine, cacheSize);
seaFloorDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_FLOOR)).cache2D("seaFloorDecoration", engine, cacheSize).waste("Sea Floor Stream");
.convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.SEA_FLOOR)).cache2D("seaFloorDecoration", engine, cacheSize);
baseBiomeIDStream = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convertAware2D((b, x, z) -> {
UUID d = regionIDStream.get(x, z);
return new UUID(b.getLoadKey().hashCode() * 818223L,
d.hashCode());
})
.cache2D("", engine, cacheSize).waste("Biome ID Stream");
.cache2D("", engine, cacheSize);
//@done
}
@@ -598,7 +602,7 @@ public class IrisComplex implements DataProvider {
return cachedPlan;
}
synchronized (childSelectionPlans) {
synchronized (childSelectionPlanLock) {
ChildSelectionPlan synchronizedPlan = childSelectionPlans.get(biome);
if (synchronizedPlan != null) {
return synchronizedPlan;
@@ -614,7 +618,9 @@ public class IrisComplex implements DataProvider {
options.add(biome);
ChildSelectionPlan createdPlan = ChildSelectionPlan.create(options);
childSelectionPlans.put(biome, createdPlan);
IdentityHashMap<IrisBiome, ChildSelectionPlan> next = new IdentityHashMap<>(childSelectionPlans);
next.put(biome, createdPlan);
childSelectionPlans = next;
return createdPlan;
}
}
@@ -389,12 +389,21 @@ public class IrisEngineMantle implements EngineMantle {
return worker.read(name, (regionName, in) ->
TectonicPlate.read(worldHeight, in, regionName.startsWith("pv."), adapter, hooks));
} finally {
if (TectonicPlate.hasError() && IrisSettings.get().getGeneral().isDumpMantleOnError()) {
File dump = IrisPlatforms.get().dataFile("dump", name + ".bin");
worker.dumpDecoded(name, dump.toPath());
} else {
IrisLogging.debug("Read Tectonic Plate " + C.DARK_GREEN + name + C.RED + " in " + Form.duration(stopwatch.getMilliseconds(), 2));
// hasError() is a consuming ThreadLocal read: evaluate exactly once. The
// dump must never throw out of this finally that would replace the
// successfully parsed plate with the dump failure, and the caller would
// overwrite 1024 good chunks with a fresh empty plate.
boolean errored = TectonicPlate.hasError();
if (errored && IrisSettings.get().getGeneral().isDumpMantleOnError()) {
try {
File dump = IrisPlatforms.get().dataFile("dump", name + ".bin");
worker.dumpDecoded(name, dump.toPath());
} catch (Throwable dumpFailure) {
IrisLogging.warn("Failed to dump mantle region " + name + " for diagnostics");
IrisLogging.reportError(dumpFailure);
}
}
IrisLogging.debug("Read Tectonic Plate " + C.DARK_GREEN + name + C.RED + " in " + Form.duration(stopwatch.getMilliseconds(), 2));
}
}
@@ -519,18 +519,7 @@ final class WorldEntitySpawner {
}
private KList<IrisEntitySpawn> spawnRandomly(List<IrisEntitySpawn> types) {
KList<IrisEntitySpawn> rarityTypes = new KList<>();
int totalRarity = 0;
for (IrisEntitySpawn i : types) {
totalRarity += IRare.get(i);
}
for (IrisEntitySpawn i : types) {
rarityTypes.addMultiple(i, totalRarity / IRare.get(i));
}
return rarityTypes;
return IRare.expandWeighted(types);
}
@Data
@@ -51,42 +51,40 @@ public class IrisBiomeActuator extends EngineAssignedActuator<PlatformBiome> {
@BlockCoordinates
@Override
public void onActuate(int x, int z, Hunk<PlatformBiome> h, boolean multicore, ChunkContext context) {
try {
PrecisionStopwatch p = PrecisionStopwatch.start();
int width = h.getWidth();
int depth = h.getDepth();
int height = h.getHeight();
Engine engine = getEngine();
Mantle<Matter> mantle = engine.getMantle().getMantle();
ChunkedDataCache<IrisBiome> biomeCache = context.getBiome();
// No catch here: a partial biome write must fail the chunk (EngineAssignedActuator
// rethrows), or the chunk is flagged REAL with default plains columns forever.
PrecisionStopwatch p = PrecisionStopwatch.start();
int width = h.getWidth();
int depth = h.getDepth();
int height = h.getHeight();
Engine engine = getEngine();
Mantle<Matter> mantle = engine.getMantle().getMantle();
ChunkedDataCache<IrisBiome> biomeCache = context.getBiome();
for (int xf = 0; xf < width; xf++) {
IrisBiome ib;
for (int zf = 0; zf < depth; zf++) {
ib = biomeCache.get(xf, zf);
String key;
for (int xf = 0; xf < width; xf++) {
IrisBiome ib;
for (int zf = 0; zf < depth; zf++) {
ib = biomeCache.get(xf, zf);
String key;
if (ib.isCustom()) {
IrisBiomeCustom custom = ib.getCustomBiome(rng, engine, x + xf, 0, z + zf);
key = getDimension().getLoadKey() + ":" + custom.getId();
} else {
key = ib.getSkyBiomeKey(rng, engine, x + xf, 0, z + zf);
}
ResolvedBiome resolved = resolve(key);
PlatformBiome biome = resolved.biome();
if (biome != null) {
h.set(xf, 0, zf, xf, height - 1, zf, biome);
}
mantle.set(x + xf, 0, z + zf, resolved.matter());
if (ib.isCustom()) {
IrisBiomeCustom custom = ib.getCustomBiome(rng, engine, x + xf, 0, z + zf);
key = getDimension().getLoadKey() + ":" + custom.getId();
} else {
key = ib.getSkyBiomeKey(rng, engine, x + xf, 0, z + zf);
}
ResolvedBiome resolved = resolve(key);
PlatformBiome biome = resolved.biome();
if (biome != null) {
h.set(xf, 0, zf, xf, height - 1, zf, biome);
}
mantle.set(x + xf, 0, z + zf, resolved.matter());
}
engine.getMetrics().getBiome().put(p.getMilliseconds());
} catch (Throwable e) {
e.printStackTrace();
}
engine.getMetrics().getBiome().put(p.getMilliseconds());
}
/**
@@ -102,6 +102,12 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
ChunkedDataCache<PlatformBlockState> rockCache = context.getRock();
int realX = xf + x;
UpperDimensionContext upperContext = getEngine().getUpperContext();
// Dimension-level ore lookups are chunk-invariant; resolving them per column paid
// four accessor chains times 256 per chunk for constants.
KList<IrisOreGenerator> dimensionSurfaceOres = hideOres ? null : dimension.getSurfaceOreGenerators();
KList<IrisOreGenerator> dimensionUndergroundOres = hideOres ? null : dimension.getUndergroundOreGenerators();
IrisOreGeneratorBounds dimensionSurfaceOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getSurfaceOreGeneratorBounds();
IrisOreGeneratorBounds dimensionUndergroundOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getUndergroundOreGeneratorBounds();
for (int zf = 0; zf < chunkDepth; zf++) {
int realZ = zf + z;
@@ -118,16 +124,12 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
PlatformBlockState rock = rockCache.get(xf, zf);
KList<IrisOreGenerator> biomeSurfaceOres = hideOres ? null : biome.getSurfaceOreGenerators();
KList<IrisOreGenerator> regionSurfaceOres = hideOres ? null : region.getSurfaceOreGenerators();
KList<IrisOreGenerator> dimensionSurfaceOres = hideOres ? null : dimension.getSurfaceOreGenerators();
KList<IrisOreGenerator> biomeUndergroundOres = hideOres ? null : biome.getUndergroundOreGenerators();
KList<IrisOreGenerator> regionUndergroundOres = hideOres ? null : region.getUndergroundOreGenerators();
KList<IrisOreGenerator> dimensionUndergroundOres = hideOres ? null : dimension.getUndergroundOreGenerators();
IrisOreGeneratorBounds biomeSurfaceOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : biome.getSurfaceOreGeneratorBounds();
IrisOreGeneratorBounds regionSurfaceOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : region.getSurfaceOreGeneratorBounds();
IrisOreGeneratorBounds dimensionSurfaceOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getSurfaceOreGeneratorBounds();
IrisOreGeneratorBounds biomeUndergroundOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : biome.getUndergroundOreGeneratorBounds();
IrisOreGeneratorBounds regionUndergroundOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : region.getUndergroundOreGeneratorBounds();
IrisOreGeneratorBounds dimensionUndergroundOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getUndergroundOreGeneratorBounds();
boolean hasSurfaceOres = biomeSurfaceOreBounds.hasOres() || regionSurfaceOreBounds.hasOres() || dimensionSurfaceOreBounds.hasOres();
boolean hasUndergroundOres = biomeUndergroundOreBounds.hasOres() || regionUndergroundOreBounds.hasOres() || dimensionUndergroundOreBounds.hasOres();
KList<PlatformBlockState> blocks = null;
@@ -68,6 +68,34 @@ public class AtomicCache<T> {
});
}
/**
* Like {@link #aquire(Supplier)} but propagates a supplier failure to the caller instead
* of swallowing it into a null return. For values that are mandatory: a caller of a
* "@NotNull" accessor should see the supplier's real exception, not a downstream NPE.
*/
public T aquireOrThrow(Supplier<T> t) {
Object v = value;
if (v != null) {
return unwrap(v);
}
synchronized (initLock) {
v = value;
if (v != null) {
return unwrap(v);
}
T computed = t.get();
if (computed == null) {
throw new IllegalStateException("Atomic cache supplier produced null");
}
value = computed;
return computed;
}
}
public T aquire(Supplier<T> t) {
Object v = value;
@@ -98,41 +98,6 @@ final class DecoratorCore {
return picked;
}
static void placeSingleUp(IrisDecorator decorator, int x, int z,
int realX, int height, int realZ, Hunk<PlatformBlockState> data,
RNG rng, IrisData irisData, boolean caveSkipFluid, EngineMantle mantle) {
PlatformBlockState bd = decorator.pickBlockData(rng, irisData, realX, realZ);
if (bd == null) {
return;
}
String half = IrisProceduralBlocks.propertyValue(bd, "half");
if (half != null) {
int lowerY = height + 1;
int upperY = height + 2;
if (!canPlaceTwoBlockPlant(data, x, z, lowerY, upperY, caveSkipFluid)) {
return;
}
try {
PlatformBlockState upper = bd.withProperty("half", topHalfValue(half));
PlatformBlockState lower = fixFacesForHunk(
bd.withProperty("half", bottomHalfValue(half)),
data, x, z, realX, lowerY, realZ, mantle);
data.set(x, lowerY, z, lower);
data.set(x, upperY, z, upper);
} catch (Throwable e) {
IrisLogging.reportError(e);
}
return;
}
int targetY = height + 1;
if (targetY < data.getHeight() && B.isAir(data.get(x, targetY, z))) {
data.set(x, targetY, z, fixFacesForHunk(bd, data, x, z, realX, targetY, realZ, mantle));
}
}
private static String topHalfValue(String half) {
return half.equals("upper") || half.equals("lower") ? "upper" : "top";
}
@@ -394,142 +394,6 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
return r.get();
}
default IrisPosition lookForBiome(IrisBiome biome, long timeout, Consumer<Integer> triesc) {
if (!getWorld().hasPlatformWorld()) {
IrisLogging.error("Cannot GOTO without a bound world (headless mode)");
return null;
}
ChronoLatch cl = new ChronoLatch(250, false);
long s = M.ms();
int cpus = (Runtime.getRuntime().availableProcessors());
if (!getDimension().getReachableBiomes(this).contains(biome)) {
return null;
}
AtomicInteger tries = new AtomicInteger(0);
AtomicBoolean found = new AtomicBoolean(false);
AtomicBoolean running = new AtomicBoolean(true);
AtomicReference<IrisPosition> location = new AtomicReference<>();
for (int i = 0; i < cpus; i++) {
J.a(() -> {
try {
Engine e;
IrisBiome b;
int x, z;
while (!found.get() && running.get()) {
try {
x = RNG.r.i(-29999970, 29999970);
z = RNG.r.i(-29999970, 29999970);
b = getSurfaceBiome(x, z);
if (b != null && b.getLoadKey() == null) {
continue;
}
if (b != null && b.getLoadKey().equals(biome.getLoadKey())) {
found.lazySet(true);
location.lazySet(new IrisPosition(x, getHeight(x, z), z));
}
tries.getAndIncrement();
} catch (Throwable ex) {
IrisLogging.reportError(ex);
ex.printStackTrace();
return;
}
}
} catch (Throwable e) {
IrisLogging.reportError(e);
e.printStackTrace();
}
});
}
while (!found.get() || location.get() == null) {
J.sleep(50);
if (cl.flip()) {
triesc.accept(tries.get());
}
if (M.ms() - s > timeout) {
running.set(false);
return null;
}
}
running.set(false);
return location.get();
}
default IrisPosition lookForRegion(IrisRegion reg, long timeout, Consumer<Integer> triesc) {
if (!getWorld().hasPlatformWorld()) {
IrisLogging.error("Cannot GOTO without a bound world (headless mode)");
return null;
}
ChronoLatch cl = new ChronoLatch(3000, false);
long s = M.ms();
int cpus = (Runtime.getRuntime().availableProcessors());
if (!getDimension().getRegions().contains(reg.getLoadKey())) {
return null;
}
AtomicInteger tries = new AtomicInteger(0);
AtomicBoolean found = new AtomicBoolean(false);
AtomicBoolean running = new AtomicBoolean(true);
AtomicReference<IrisPosition> location = new AtomicReference<>();
for (int i = 0; i < cpus; i++) {
J.a(() -> {
Engine e;
IrisRegion b;
int x, z;
while (!found.get() && running.get()) {
try {
x = RNG.r.i(-29999970, 29999970);
z = RNG.r.i(-29999970, 29999970);
b = getRegion(x, z);
if (b != null && b.getLoadKey() != null && b.getLoadKey().equals(reg.getLoadKey())) {
found.lazySet(true);
location.lazySet(new IrisPosition(x, getHeight(x, z), z));
}
tries.getAndIncrement();
} catch (Throwable xe) {
IrisLogging.reportError(xe);
xe.printStackTrace();
return;
}
}
});
}
while (!found.get() || location.get() != null) {
J.sleep(50);
if (cl.flip()) {
triesc.accept(tries.get());
}
if (M.ms() - s > timeout) {
triesc.accept(tries.get());
running.set(false);
return null;
}
}
triesc.accept(tries.get());
running.set(false);
return location.get();
}
double getGeneratedPerSecond();
default int getHeight() {
@@ -18,6 +18,7 @@
package art.arcane.iris.engine.framework;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.project.context.ChunkContext;
import art.arcane.volmlib.util.documentation.BlockCoordinates;
import art.arcane.iris.util.project.hunk.Hunk;
@@ -31,6 +32,21 @@ public abstract class EngineAssignedActuator<T> extends EngineAssignedComponent
@BlockCoordinates
@Override
public void actuate(int x, int z, Hunk<T> output, boolean multicore, ChunkContext context) {
onActuate(x, z, output, multicore, context);
try {
onActuate(x, z, output, multicore, context);
} catch (Throwable e) {
// Same contract as EngineAssignedModifier.modify: a failed actuator leaves a
// half-written chunk. Never continue as if it succeeded; let the chunk
// generation failure path abort the chunk instead of persisting it.
IrisLogging.error("Actuator Failure: " + getName());
IrisLogging.reportError(e);
if (e instanceof Error error) {
throw error;
}
if (e instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw new IllegalStateException("Actuator Failure: " + getName(), e);
}
}
}
@@ -24,16 +24,12 @@ import art.arcane.iris.util.project.context.ChunkContext;
import art.arcane.iris.util.project.context.IrisContext;
import art.arcane.volmlib.util.documentation.BlockCoordinates;
import art.arcane.iris.util.project.hunk.Hunk;
import art.arcane.volmlib.util.math.RollingSequence;
import art.arcane.iris.util.common.parallel.BurstExecutor;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.iris.spi.PlatformBiome;
import art.arcane.iris.spi.PlatformBlockState;
public interface EngineMode extends Staged {
RollingSequence r = new RollingSequence(64);
RollingSequence r2 = new RollingSequence(256);
void close();
Engine getEngine();
@@ -46,16 +42,45 @@ public interface EngineMode extends Staged {
return (x, z, blocks, biomes, multicore, ctx) -> {
BurstExecutor e = burst().burst(stages.length);
e.setMulticore(multicore);
// BurstExecutor.complete() logs-and-swallows stage failures; without re-propagation
// a multicore run would commit a half-written chunk that the inline (production)
// path correctly aborts.
java.util.concurrent.atomic.AtomicReference<Throwable> failure = new java.util.concurrent.atomic.AtomicReference<>();
for (EngineStage i : stages) {
e.queue(() -> {
if (failure.get() != null) {
return;
}
try (IrisContext.Scope stageScope = IrisContext.open(getEngine(), ctx.getGenerationSessionId(), ctx)) {
i.generate(x, z, blocks, biomes, multicore, ctx);
} catch (Throwable t) {
failure.compareAndSet(null, t);
// Rethrow so the inline (multicore=false) path still aborts out of
// queue() on the first failure, exactly as before.
if (t instanceof Error error) {
throw error;
}
if (t instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw new IllegalStateException(t);
}
});
}
e.complete();
Throwable t = failure.get();
if (t != null) {
if (t instanceof Error error) {
throw error;
}
if (t instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw new IllegalStateException("Burst stage failure during chunk generation", t);
}
};
}
@@ -166,7 +166,11 @@ public final class LootResolver {
if (mode == IrisLootMode.FALLBACK && !fallback) {
return;
}
if (mode == IrisLootMode.CLEAR || mode == IrisLootMode.REPLACE) {
if (mode == IrisLootMode.CLEAR) {
sources.clear();
return;
}
if (mode == IrisLootMode.REPLACE) {
sources.clear();
}
sources.addAll(additions);
@@ -258,13 +258,13 @@ public interface EngineMantle extends MatterGenerator {
MantleChunk<Matter> chunk = getMantle().getChunk(x, z).use();
try {
chunk.raiseFlagUnchecked(MantleFlag.CLEANED, () -> {
chunk.deleteSlices(PlatformBlockState.class);
chunk.deleteSlices(String.class);
chunk.deleteSlices(UpdateMatter.class);
chunk.deleteSlices(MatterCavern.class);
chunk.deleteSlices(MatterFluidBody.class);
chunk.deleteSlices(MatterMarker.class);
chunk.deleteSlices(TreeBlockMaterial.class);
MantleSliceRetention.deleteUnlessRetained(chunk, PlatformBlockState.class);
MantleSliceRetention.deleteUnlessRetained(chunk, String.class);
MantleSliceRetention.deleteUnlessRetained(chunk, UpdateMatter.class);
MantleSliceRetention.deleteUnlessRetained(chunk, MatterCavern.class);
MantleSliceRetention.deleteUnlessRetained(chunk, MatterFluidBody.class);
MantleSliceRetention.deleteUnlessRetained(chunk, MatterMarker.class);
MantleSliceRetention.deleteUnlessRetained(chunk, TreeBlockMaterial.class);
chunk.trimSlices();
});
} finally {
@@ -276,10 +276,10 @@ public interface EngineMantle extends MatterGenerator {
MantleChunk<Matter> chunk = getMantle().getChunk(x, z).use();
try {
chunk.raiseFlagUnchecked(MantleFlag.CLEANED, () -> {
chunk.deleteSlices(PlatformBlockState.class);
chunk.deleteSlices(UpdateMatter.class);
chunk.deleteSlices(MatterCavern.class);
chunk.deleteSlices(MatterFluidBody.class);
MantleSliceRetention.deleteUnlessRetained(chunk, PlatformBlockState.class);
MantleSliceRetention.deleteUnlessRetained(chunk, UpdateMatter.class);
MantleSliceRetention.deleteUnlessRetained(chunk, MatterCavern.class);
MantleSliceRetention.deleteUnlessRetained(chunk, MatterFluidBody.class);
chunk.trimSlices();
});
} finally {
@@ -0,0 +1,71 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.engine.mantle;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.matter.Matter;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Process-wide registry of mantle slice types that survive chunk cleanup. Declared through
* IrisToolbelt/IrisModdedAPI's retainMantleDataForSlice; honored by both EngineMantle cleanup
* paths (normal trim and pregen force-cleanup). Retained slices are not a leak: they persist to
* tectonic plates and unload with the region - the cost is larger region files.
*
* <p>The block-state slice is deliberately never retainable: it is the largest mantle consumer
* and is fully regenerable, so retaining it would balloon every region file with no consumer.
*/
public final class MantleSliceRetention {
private static final Set<String> retained = ConcurrentHashMap.newKeySet();
private MantleSliceRetention() {
}
public static void retain(String className) {
if (className == null || PlatformBlockState.class.getCanonicalName().equals(className)) {
return;
}
if (retained.add(className)) {
IrisLogging.info("Mantle slice retained across chunk cleanup: " + className);
}
}
public static boolean isRetained(String className) {
return className != null && retained.contains(className);
}
public static boolean isRetained(Class<?> sliceType) {
return sliceType != null && retained.contains(sliceType.getCanonicalName());
}
static void deleteUnlessRetained(MantleChunk<Matter> chunk, Class<?> sliceType) {
if (isRetained(sliceType)) {
return;
}
chunk.deleteSlices(sliceType);
}
static void clearForTesting() {
retained.clear();
}
}
@@ -81,14 +81,21 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable {
if (foliaMaintenance && IrisSettings.get().getGeneral().isDebug()) {
IrisLogging.info("MantleWriter using sequential chunk prefetch for maintenance regen at " + x + "," + z + ".");
}
mantle.getChunks(
x - radius,
x + radius,
z - radius,
z + radius,
parallelism,
this::storePrefetchedChunk
);
// try-with-resources never calls close() when the initializer throws, so a failed
// prefetch must release the permits already pinned into the window here.
try {
mantle.getChunks(
x - radius,
x + radius,
z - radius,
z + radius,
parallelism,
this::storePrefetchedChunk
);
} catch (Throwable e) {
close();
throw e;
}
}
private static Set<IrisPosition> getBallooned(Set<IrisPosition> vset, double radius) {
@@ -403,7 +410,9 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable {
@Override
public PlatformBlockState get(int x, int y, int z) {
PlatformBlockState block = getData(x, y, z, PlatformBlockState.class);
// Read-only probe: getDataIfPresent returns the identical answer without materializing
// a 16^3 section + slice on a miss the way getData's getOrCreate path does.
PlatformBlockState block = getDataIfPresent(x, y, z, PlatformBlockState.class);
if (block == null)
return AIR;
return block;
@@ -416,7 +425,7 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable {
@Override
public boolean isCarved(int x, int y, int z) {
return getData(x, y, z, MatterCavern.class) != null;
return getDataIfPresent(x, y, z, MatterCavern.class) != null;
}
public byte[] getCarvedColumn(int x, int z, int height) {
@@ -38,6 +38,15 @@ public final class CarveOrphanSweep {
boolean isCarved(int localX, int y, int localZ);
void markCarved(int localX, int y, int localZ);
/**
* Cheap pre-check: may any block in [minY, maxY] be carved at all? Default true keeps
* every implementor correct; the mantle-backed access answers from section slice
* presence so an uncarved chunk skips the full band fill + scan.
*/
default boolean mayContainCarvedCells(int minY, int maxY) {
return true;
}
}
private CarveOrphanSweep() {
@@ -74,6 +83,12 @@ public final class CarveOrphanSweep {
return 0;
}
if (!access.mayContainCarvedCells(bandFloor, bandTop)) {
// Same early return the carvedPresent check reaches, without zero-filling and
// scanning the whole band first.
return 0;
}
int bandHeight = bandTop - bandFloor + 1;
int cellCount = bandHeight * CHUNK_AREA;
SweepScratch scratch = SCRATCH.get();
@@ -225,6 +240,19 @@ public final class CarveOrphanSweep {
cachedSlice = null;
}
@Override
public boolean mayContainCarvedCells(int minY, int maxY) {
int minSection = Math.max(0, minY >> 4);
int maxSection = Math.min(chunk.sectionCount() - 1, maxY >> 4);
for (int sectionIndex = minSection; sectionIndex <= maxSection; sectionIndex++) {
Matter section = chunk.get(sectionIndex);
if (section != null && section.getSlice(MatterCavern.class) != null) {
return true;
}
}
return false;
}
private MatterSlice<MatterCavern> resolveSlice(int sectionIndex) {
if (sectionIndex == cachedSectionIndex) {
return cachedSlice;
@@ -82,6 +82,17 @@ public class MantleCarvingComponent extends IrisMantleComponent {
super(engineMantle, ReservedFlag.CARVED, 0);
}
@Override
public void hotload() {
super.hotload();
// Hotload swaps in fresh IrisCaveProfile instances (identity keys), so retained
// entries would strand a full carver set pinning the closed IrisData per reload.
// Carvers rebuild deterministically from the carve seed, so output is unchanged.
synchronized (profileCarverLock) {
profileCarvers = new IdentityHashMap<>();
}
}
@Override
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
IrisComplex complex = context.getComplex();
@@ -259,15 +270,16 @@ public class MantleCarvingComponent extends IrisMantleComponent {
continue;
}
SimdKernels kernels = SimdSupport.kernels();
double totalWeight = kernels.sum(weights, weights.length);
double maxWeight = kernels.max(weights, weights.length);
// averageWeight ranks and orders carve passes, so it must be bit-identical across
// installations: kernels.sum reassociates FP addition under the vector kernel,
// making profile order depend on a JVM flag. max() is order-independent and safe.
double maxWeight = SimdSupport.kernels().max(weights, weights.length);
if (maxWeight < MIN_WEIGHT) {
continue;
}
double averageWeight = totalWeight / CHUNK_AREA;
double averageWeight = computeAverageWeight(weights);
columnWeightedProfiles.add(new WeightedProfile(profile, weights, averageWeight, null, columnWeightedProfiles.size()));
}
@@ -85,23 +85,33 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
}
}
// The IdentityHashMaps are lookup indices only. Iteration MUST follow the encounter
// order of the deterministic 0..255 column scan: identity-hash order changes per JVM
// run, and the shared chunkRng makes placement output depend on iteration order.
IdentityHashMap<IrisFloatingChildBiomes, KList<Integer>> entryColumns = new IdentityHashMap<>();
IdentityHashMap<IrisFloatingChildBiomes, KList<Integer>> bottomEntryColumns = new IdentityHashMap<>();
KList<IrisFloatingChildBiomes> entryOrder = new KList<>();
KList<IrisFloatingChildBiomes> bottomEntryOrder = new KList<>();
for (int i = 0; i < 256; i++) {
FloatingIslandSample s = samples[i];
if (s == null || s.entry == null) {
continue;
}
entryColumns.computeIfAbsent(s.entry, e -> new KList<>()).add(i);
entryColumns.computeIfAbsent(s.entry, e -> {
entryOrder.add(e);
return new KList<>();
}).add(i);
IrisFloatingChildBiomes bottomEntry = s.bottomEntry();
if (bottomEntry != null) {
bottomEntryColumns.computeIfAbsent(bottomEntry, e -> new KList<>()).add(i);
bottomEntryColumns.computeIfAbsent(bottomEntry, e -> {
bottomEntryOrder.add(e);
return new KList<>();
}).add(i);
}
}
for (Map.Entry<IrisFloatingChildBiomes, KList<Integer>> ec : entryColumns.entrySet()) {
IrisFloatingChildBiomes entry = ec.getKey();
KList<Integer> columns = ec.getValue();
for (IrisFloatingChildBiomes entry : entryOrder) {
KList<Integer> columns = entryColumns.get(entry);
if (columns.isEmpty()) {
continue;
}
@@ -137,9 +147,8 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
}
}
for (Map.Entry<IrisFloatingChildBiomes, KList<Integer>> ec : bottomEntryColumns.entrySet()) {
IrisFloatingChildBiomes entry = ec.getKey();
KList<Integer> columns = ec.getValue();
for (IrisFloatingChildBiomes entry : bottomEntryOrder) {
KList<Integer> columns = bottomEntryColumns.get(entry);
if (columns.isEmpty()) {
continue;
}
@@ -103,11 +103,13 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode {
registerStage(sCave);
registerStage(sPost);
registerStage(sFloatingTerrainSolid);
registerStage(burst(
sDeposit,
sInsertMatter,
sDecorant
));
// Never burst these three: all of them write the same block hunk (and sDecorant reads
// the surface sInsertMatter writes), so parallel order is scheduler-dependent. The
// production path already runs them inline in this order; sequential registration
// makes studio (the only multicore path) match production and the goldenhash baseline.
registerStage(sDeposit);
registerStage(sInsertMatter);
registerStage(sDecorant);
registerStage(sFloatingDecorate);
registerStage(sPerfection);
registerStage(sCustom);
@@ -563,6 +563,18 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
}
}
/**
* 1-in-16 marker roll from a SplitMix64 finalizer over (carve seed, block position, salt).
* Deterministic per seed and position, thread-order independent, allocation free.
*/
private boolean markerRoll(int x, int y, int z, long salt) {
long h = (getEngine().getSeedManager().getCarve() + salt) ^ BlockPosition.toLong(x, y, z);
h = (h ^ (h >>> 30)) * 0xBF58476D1CE4E5B9L;
h = (h ^ (h >>> 27)) * 0x94D049BB133111EBL;
h ^= h >>> 31;
return (h & 15L) == 0L;
}
private void processZone(Hunk<PlatformBlockState> output, MantleChunk<Matter> mc, Mantle<Matter> mantle, CaveZone zone, int rx, int rz, int xx, int zz, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache) {
int center = (zone.floor + zone.ceiling) / 2;
int maxY = output.getHeight();
@@ -576,11 +588,14 @@ public class IrisCarveModifier extends EngineAssignedModifier<PlatformBlockState
output.setRaw(rx, zone.ceiling, rz, AIR);
}
if (M.r(1D / 16D)) {
// Seed-derived and position-keyed, never Math.random(): these markers persist into the
// mantle and drive cave spawning, so the same seed must stamp the same markers on
// every run and every platform. Inline mix, no allocation on this hot path.
if (markerRoll(xx, zone.ceiling, zz, 0x9E3779B97F4A7C15L)) {
mantle.set(xx, zone.ceiling, zz, MarkerMatter.CAVE_CEILING);
}
if (M.r(1D / 16D)) {
if (markerRoll(xx, zone.floor, zz, 0xC2B2AE3D27D4EB4FL)) {
mantle.set(xx, zone.floor, zz, MarkerMatter.CAVE_FLOOR);
}
@@ -27,30 +27,38 @@ public class IrisCustomModifier extends EngineAssignedModifier<PlatformBlockStat
BurstExecutor burst = MultiBurst.burst.burst(output.getHeight());
burst.setMulticore(multicore);
for (int y = 0; y < output.getHeight(); y++) {
int finalY = y;
burst.queue(() -> {
for (int rX = 0; rX < output.getWidth(); rX++) {
for (int rZ = 0; rZ < output.getDepth(); rZ++) {
PlatformBlockState b = output.get(rX, finalY, rZ);
if (b == null || !b.isCustom()) {
continue;
}
String placementKey = b.deferredPlacementKey();
PlatformBlockState baseState = b.placementBaseState();
if (placementKey == null || baseState == null) {
continue;
}
// complete() must run before release() even when queueing throws detached burst
// tasks must never write into a released chunk, and a lost permit wedges close().
try {
for (int y = 0; y < output.getHeight(); y++) {
int finalY = y;
burst.queue(() -> {
for (int rX = 0; rX < output.getWidth(); rX++) {
for (int rZ = 0; rZ < output.getDepth(); rZ++) {
PlatformBlockState b = output.get(rX, finalY, rZ);
if (b == null || !b.isCustom()) {
continue;
}
String placementKey = b.deferredPlacementKey();
PlatformBlockState baseState = b.placementBaseState();
if (placementKey == null || baseState == null) {
continue;
}
mc.getOrCreate(finalY >> 4)
.slice(Identifier.class)
.set(rX, finalY & 15, rZ, Identifier.fromString(placementKey));
output.set(rX, finalY, rZ, baseState);
mc.getOrCreate(finalY >> 4)
.slice(Identifier.class)
.set(rX, finalY & 15, rZ, Identifier.fromString(placementKey));
output.set(rX, finalY, rZ, baseState);
}
}
}
});
});
}
} finally {
try {
burst.complete();
} finally {
mc.release();
}
}
burst.complete();
mc.release();
}
}
@@ -77,9 +77,14 @@ public class IrisDepositModifier extends EngineAssignedModifier<PlatformBlockSta
long finalSeed = seed * ++mask;
burst.queue(() -> generate(k, chunk, terrain, rng.nextParallelRNG(finalSeed), x, z, false, context));
}
burst.complete();
} finally {
chunk.release();
// complete() must run before release() even when queueing throws already
// submitted burst tasks must never write into a released chunk.
try {
burst.complete();
} finally {
chunk.release();
}
}
}
@@ -48,7 +48,9 @@ public class IrisPostModifier extends EngineAssignedModifier<PlatformBlockState>
@Override
public void onModify(int x, int z, Hunk<PlatformBlockState> output, boolean multicore, ChunkContext context) {
PrecisionStopwatch p = PrecisionStopwatch.start();
Hunk<PlatformBlockState> sync = output.synchronize();
// The post stage runs sequentially on production (multicore false); an uncontended
// monitor per probe is still a monitor times ~10k probes per chunk.
Hunk<PlatformBlockState> sync = multicore ? output.synchronize() : output;
int width = output.getWidth();
int depth = output.getDepth();
int planeWidth = width + 2;
@@ -20,11 +20,8 @@ package art.arcane.iris.engine.object;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class FloatingObjectFootprint {
private static final ConcurrentHashMap<String, FloatingObjectFootprint> CACHE = new ConcurrentHashMap<>();
private final int lowestSolidKeyY;
private final int highestSolidKeyY;
private final int centerX;
@@ -49,12 +46,17 @@ public class FloatingObjectFootprint {
this.footprintXZ = footprintXZ;
}
/**
* Memoized per object instance, never in a global map: loadKey + declared dimensions is
* not unique across packs or across hotloads (a block edit keeps the header W/H/D), so a
* shared key space served stale or foreign footprints. A reload builds a new IrisObject,
* which drops the memo for free.
*/
public static FloatingObjectFootprint compute(IrisObject obj) {
String cacheKey = obj.getLoadKey() + "@" + obj.getW() + "x" + obj.getH() + "x" + obj.getD();
return CACHE.computeIfAbsent(cacheKey, k -> doCompute(obj, k));
return obj.floatingFootprint.aquire(() -> doCompute(obj));
}
private static FloatingObjectFootprint doCompute(IrisObject obj, String cacheKey) {
private static FloatingObjectFootprint doCompute(IrisObject obj) {
int cx = obj.getCenter().getBlockX();
int cy = obj.getCenter().getBlockY();
int cz = obj.getCenter().getBlockZ();
@@ -93,5 +93,23 @@ public interface IRare {
return v instanceof IRare ? Math.max(1, ((IRare) v).getRarity()) : 1;
}
/**
* Expands a candidate list into a rarity-weighted list: each entry appears totalRarity/rarity
* times, so picking uniformly from the result applies rarity exactly once. Both the Bukkit and
* modded entity spawners select through this expansion; rarity must never be applied a second
* time per spawn position.
*/
static <T> KList<T> expandWeighted(List<T> possibilities) {
KList<T> rarityTypes = new KList<>();
int totalRarity = 0;
for (T i : possibilities) {
totalRarity += get(i);
}
for (T i : possibilities) {
rarityTypes.addMultiple(i, totalRarity / get(i));
}
return rarityTypes;
}
int getRarity();
}
@@ -83,11 +83,8 @@ public class IrisAxisRotationClamp {
}
if (isUnlimited()) {
if (interval < 1) {
interval = 1;
}
return Math.toRadians((interval * (Math.ceil(Math.abs((rng % 360D) / interval)))) % 360D);
double resolvedInterval = interval < 1 ? 1 : interval;
return Math.toRadians((resolvedInterval * (Math.ceil(Math.abs((rng % 360D) / resolvedInterval)))) % 360D);
}
if (min == max && min != 0) {
@@ -250,8 +250,18 @@ public class IrisBiome extends IrisRegistrant implements IRare {
return this;
}
public synchronized IrisBiome withInferredType(InferredType type) {
public IrisBiome withInferredType(InferredType type) {
Objects.requireNonNull(type, "type");
// Lock-free fast path on the volatile field: this runs per column per implode level
// against biome instances shared by every burst thread. The variant map below stays
// monitor-guarded (plain EnumMap, not safe for concurrent reads during writes).
if (inferredType == type) {
return this;
}
return withInferredTypeSlow(type);
}
private synchronized IrisBiome withInferredTypeSlow(InferredType type) {
if (inferredType == null) {
inferredType = type;
return this;
@@ -125,8 +125,10 @@ final class IrisBiomeLayerGenerator {
}
KList<CNG> heightGenerators = getLayerHeightGenerators(biome, random, rdata);
// Ceiling layers reuse the surface-layer height generators, so entries beyond layers.size() have no generator; skip them.
int usableLayers = Math.min(layerCount, heightGenerators.size());
for (int i = 0; i < layerCount; i++) {
for (int i = 0; i < usableLayers; i++) {
IrisBiomePaletteLayer layer = ceilingLayers.get(i);
double zoom = layer.getZoom();
CNG hgen = heightGenerators.get(i);
@@ -63,10 +63,20 @@ final class IrisBiomeOres {
}
static IrisOreGeneratorBounds getSurfaceOreGeneratorBounds(IrisBiome biome) {
// getIfPresent fast path: aquire allocates a capturing lambda even on a hit, and this
// runs per column on the terrain hot path.
IrisOreGeneratorBounds cached = biome.getSurfaceOreBoundsCache().getIfPresent();
if (cached != null) {
return cached;
}
return biome.getSurfaceOreBoundsCache().aquire(() -> IrisOreGeneratorBounds.of(getSurfaceOres(biome)));
}
static IrisOreGeneratorBounds getUndergroundOreGeneratorBounds(IrisBiome biome) {
IrisOreGeneratorBounds cached = biome.getUndergroundOreBoundsCache().getIfPresent();
if (cached != null) {
return cached;
}
return biome.getUndergroundOreBoundsCache().aquire(() -> IrisOreGeneratorBounds.of(getUndergroundOres(biome)));
}
@@ -170,8 +170,6 @@ public class IrisDimension extends IrisRegistrant {
@MaxNumber(16)
@Desc("Minimum surface-support buffer, in blocks, applied to every surface object placement in this dimension. A placement may ask for more but never less.")
private int objectSurfaceSupportBuffer = 2;
@Desc("Unused. This field is not read by the engine.")
private Boolean forceConvertTo320Height = false;
@Desc("The world environment")
private IrisEnvironment environment = IrisEnvironment.NORMAL;
@RegistryListResource(IrisRegion.class)
@@ -257,16 +255,10 @@ public class IrisDimension extends IrisRegistrant {
@ArrayType(min = 1, type = IrisShapedGeneratorStyle.class)
@Desc("Overlay additional noise on top of the interoplated terrain.")
private KList<IrisShapedGeneratorStyle> overlayNoise = new KList<>();
@MinNumber(0.0001)
@MaxNumber(512)
@Desc("Unused. This field is not read by the engine; rock palette styling comes from rockPalette itself.")
private double rockZoom = 5;
@Desc("The palette of blocks for 'stone'")
private IrisMaterialPalette rockPalette = new IrisMaterialPalette().qclear().qadd("stone");
@Desc("The dimension fluid block palette used for ocean columns and cave aquifers.")
private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("water");
@Desc("Unused. This field is not read by the engine and no longer affects explorer maps.")
private boolean disableExplorerMaps = false;
@Desc("Collection of ores to be generated")
@ArrayType(type = IrisOreGenerator.class, min = 1)
private KList<IrisOreGenerator> ores = new KList<>();
@@ -30,6 +30,12 @@ public final class IrisDimensionType {
private final int height;
private final int minY;
public static final int MIN_HEIGHT = 16;
public static final int MAX_HEIGHT = 4064;
public static final int MIN_MIN_Y = -2032;
public static final int MAX_MIN_Y = 2031;
public static final int HEIGHT_STEP = 16;
public IrisDimensionType(
@NonNull IDataFixer.Dimension base,
@NonNull IrisDimensionTypeOptions options,
@@ -39,10 +45,10 @@ public final class IrisDimensionType {
) {
if (logicalHeight > height) throw new IllegalArgumentException("Logical height cannot be greater than height");
if (logicalHeight < 0) throw new IllegalArgumentException("Logical height cannot be less than zero");
if (height < 16 || height > 4064 ) throw new IllegalArgumentException("Height must be between 16 and 4064");
if ((height & 15) != 0) throw new IllegalArgumentException("Height must be a multiple of 16");
if (minY < -2032 || minY > 2031) throw new IllegalArgumentException("Min Y must be between -2032 and 2031");
if ((minY & 15) != 0) throw new IllegalArgumentException("Min Y must be a multiple of 16");
if (height < MIN_HEIGHT || height > MAX_HEIGHT) throw new IllegalArgumentException("Height must be between 16 and 4064");
if ((height & (HEIGHT_STEP - 1)) != 0) throw new IllegalArgumentException("Height must be a multiple of 16");
if (minY < MIN_MIN_Y || minY > MAX_MIN_Y) throw new IllegalArgumentException("Min Y must be between -2032 and 2031");
if ((minY & (HEIGHT_STEP - 1)) != 0) throw new IllegalArgumentException("Min Y must be a multiple of 16");
this.base = base;
this.options = options;
@@ -77,8 +77,6 @@ public class IrisGeneratorStyle {
@MinNumber(0.00001)
@Desc("The Output multiplier. Only used if parent is fracture.")
private double multiplier = 1;
@Desc("If set to true, each dimension will be fractured with a different order of input coordinates. This is usually 2 or 3 times slower than normal.")
private boolean axialFracturing = false;
@Desc("Apply a generator to the coordinate field fed into this parent generator. I.e. Distort your generator with another generator.")
private IrisGeneratorStyle fracture = null;
@MinNumber(0.01562)
@@ -123,7 +121,7 @@ public class IrisGeneratorStyle {
}
private int hash() {
return Objects.hash(expression, imageMapHash(), multiplier, axialFracturing, fracture != null ? fracture.hash() : 0, exponent, cacheSize, zoom, cellularZoom, cellularFrequency, style);
return Objects.hash(expression, imageMapHash(), multiplier, fracture != null ? fracture.hash() : 0, exponent, cacheSize, zoom, cellularZoom, cellularFrequency, style);
}
public int prebakeSignature() {
@@ -191,7 +189,6 @@ public class IrisGeneratorStyle {
}
cng = cng.scale(1D / zoom).pow(exponent).bake();
cng.setTrueFracturing(axialFracturing);
if (fracture != null) {
cng.fractureWith(fracture.createNoCache(rng.nextParallelRNG(2934), data, false,
@@ -20,13 +20,13 @@ package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.Desc;
@Desc("A loot mode is used to describe what to do with the existing loot layers before adding this loot. Using ADD will simply add this table to the building list of tables (i.e. add dimension tables, region tables then biome tables). By using clear or replace, you remove the parent tables before and add just your tables.")
@Desc("A loot mode is used to describe what to do with the existing loot layers before adding this loot. ADD appends to the building list of tables (dimension tables, then region tables, then biome tables). REPLACE swaps the parent tables for this reference's tables. CLEAR suppresses loot entirely at this point.")
public enum IrisLootMode {
@Desc("Add to the existing parent loot tables")
ADD,
@Desc("Clear all loot tables then add this table")
@Desc("Clear all parent loot tables and contribute nothing at this level. Any tables listed here are ignored.")
CLEAR,
@Desc("Replace all loot tables with this table (same as clear)")
@Desc("Replace all parent loot tables with this reference's tables")
REPLACE,
@Desc("Only use when there was no loot table defined by an object")
FALLBACK
@@ -42,7 +42,7 @@ public class IrisLootReference {
public static final double MAX_MULTIPLIER = 16D;
private final transient AtomicCache<KList<IrisLootTable>> tt = new AtomicCache<>();
@Desc("ADD = add on top of parent tables. REPLACE and CLEAR both clear parent tables first, then add these. FALLBACK = only used when nothing else defined a table.")
@Desc("ADD = add on top of parent tables. REPLACE = clear parent tables, then add these. CLEAR = clear parent tables and add nothing, even if tables are listed. FALLBACK = only used when nothing else defined a table.")
private IrisLootMode mode = IrisLootMode.ADD;
@RegistryListResource(IrisLootTable.class)
@ArrayType(min = 1, type = String.class)
@@ -80,6 +80,7 @@ public class IrisObject extends IrisRegistrant {
@Setter
protected transient AtomicCache<AxisAlignedBB> aabb = new AtomicCache<>();
transient final AtomicCache<KList<IrisBlockVector>> surfaceSupportOffsets = new AtomicCache<>();
transient final AtomicCache<FloatingObjectFootprint> floatingFootprint = new AtomicCache<>();
@Getter
VectorMap<PlatformBlockState> blocks;
@Getter
@@ -174,11 +175,25 @@ public class IrisObject extends IrisRegistrant {
}
public void shrinkwrap() {
IrisObjectShaping.shrinkwrap(this);
// Instances are loader cached and shared across generation threads; mutating the
// volume without the write lock let a concurrent placement mix pre-shrink anchors
// with post-shrink blocks. Rotation paths call the package-private statics while
// already holding this (reentrant) lock.
writeLock.lock();
try {
IrisObjectShaping.shrinkwrap(this);
} finally {
writeLock.unlock();
}
}
public void clean() {
IrisObjectShaping.clean(this);
writeLock.lock();
try {
IrisObjectShaping.clean(this);
} finally {
writeLock.unlock();
}
}
public IrisBlockVector getSigned(int x, int y, int z) {
@@ -200,6 +215,7 @@ public class IrisObject extends IrisRegistrant {
}
surfaceSupportOffsets.reset();
floatingFootprint.reset();
}
public void setUnsignedTile(int x, int y, int z, TileData tile) {
@@ -229,6 +245,7 @@ public class IrisObject extends IrisRegistrant {
}
surfaceSupportOffsets.reset();
floatingFootprint.reset();
}
public int place(int x, int z, IObjectPlacer placer, IrisObjectPlacement config, RNG rng, IrisData rdata) {
@@ -94,6 +94,7 @@ public final class IrisObjectIO {
static void readLegacy(IrisObject self, InputStream in) throws IOException {
self.surfaceSupportOffsets.reset();
self.floatingFootprint.reset();
DataInputStream din = new DataInputStream(in);
self.w = din.readInt();
self.h = din.readInt();
@@ -126,6 +127,7 @@ public final class IrisObjectIO {
static void read(IrisObject self, InputStream in) throws Throwable {
self.surfaceSupportOffsets.reset();
self.floatingFootprint.reset();
DataInputStream din = new DataInputStream(in);
self.w = din.readInt();
self.h = din.readInt();
@@ -142,11 +144,18 @@ public final class IrisObjectIO {
palette.add(din.readUTF());
}
// Resolve the palette once: B.getState per BLOCK was a registry lookup times the
// block count (tens of thousands) instead of times the palette size (hundreds).
PlatformBlockState[] resolved = new PlatformBlockState[palette.size()];
for (i = 0; i < resolved.length; i++) {
resolved[i] = B.getState(palette.get(i));
}
s = din.readInt();
for (i = 0; i < s; i++) {
IrisBlockVector pos = new IrisBlockVector(din.readShort(), din.readShort(), din.readShort());
PlatformBlockState data = B.getState(palette.get(din.readShort()));
PlatformBlockState data = resolved[din.readShort()];
if (isStructureMarker(data)) {
continue;
}
@@ -166,6 +175,10 @@ public final class IrisObjectIO {
} catch (Throwable e) {
if (!(e instanceof HeaderException))
IrisLogging.reportError(e);
// The V2 parse populates blocks/states incrementally; a mid-file failure must not
// leave those entries to be merged with the legacy parse of the same file.
self.blocks.clear();
self.states.clear();
try (var fin = new BufferedInputStream(new FileInputStream(file))) {
readLegacy(self, fin);
}
@@ -180,7 +193,44 @@ public final class IrisObjectIO {
return material.equals("minecraft:jigsaw") || material.equals("minecraft:structure_block") || material.equals("minecraft:structure_void");
}
/**
* The .iob V2 format stores the palette count, palette indices, and block coordinates as
* shorts. Values beyond the short range used to wrap silently and corrupt the object; every
* write path now rejects them with a descriptive error before any byte is written.
*/
static void validateWritable(IrisObject self) throws IOException {
KList<String> palette = new KList<>();
for (PlatformBlockState i : self.blocks.values()) {
palette.addIfMissing(i.key());
}
if (palette.size() > MAX_PALETTE_ENTRIES) {
throw new IOException("Object '" + self.getLoadKey() + "' has " + palette.size()
+ " distinct block states; the .iob format supports at most " + MAX_PALETTE_ENTRIES + ".");
}
for (var entry : self.blocks) {
requireShortCoordinates(self, "block", entry.getKey());
}
for (var entry : self.states) {
requireShortCoordinates(self, "tile", entry.getKey());
}
}
private static void requireShortCoordinates(IrisObject self, String kind, IrisBlockVector position) throws IOException {
requireShort(self, kind, "x", position.getBlockX(), position);
requireShort(self, kind, "y", position.getBlockY(), position);
requireShort(self, kind, "z", position.getBlockZ(), position);
}
private static void requireShort(IrisObject self, String kind, String axis, int value, IrisBlockVector position) throws IOException {
if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) {
throw new IOException("Object '" + self.getLoadKey() + "' " + kind + " at (" + position.getBlockX()
+ "," + position.getBlockY() + "," + position.getBlockZ()
+ ") exceeds the .iob coordinate range of ±32767 on the " + axis + " axis (" + value + ").");
}
}
static void write(IrisObject self, OutputStream o) throws IOException {
validateWritable(self);
DataOutputStream dos = new DataOutputStream(o);
dos.writeInt(self.w);
dos.writeInt(self.h);
@@ -219,6 +269,7 @@ public final class IrisObjectIO {
}
static void write(IrisObject self, OutputStream o, VolmitSender sender) throws IOException {
validateWritable(self);
AtomicReference<IOException> ref = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
new Job() {
@@ -310,6 +361,9 @@ public final class IrisObjectIO {
return;
}
// Validate before opening the stream: FileOutputStream truncates, and a rejected
// object must leave the existing .iob untouched.
validateWritable(self);
try (FileOutputStream out = new FileOutputStream(file)) {
write(self, out);
}
@@ -320,6 +374,7 @@ public final class IrisObjectIO {
return;
}
validateWritable(self);
try (FileOutputStream out = new FileOutputStream(file)) {
write(self, out, sender);
}
@@ -151,8 +151,6 @@ public class IrisObjectPlacement {
private boolean bore = false;
@Desc("Use a generator to warp the field of coordinates. Using simplex for example would make a square placement warp like a flag")
private IrisGeneratorStyle warp = new IrisGeneratorStyle(NoiseStyle.FLAT);
@Desc("Unused. This field is not read by the placement engine.")
private boolean translateCenter = false;
@Desc("The placement mode")
private ObjectPlaceMode mode = ObjectPlaceMode.CENTER_HEIGHT;
@ArrayType(min = 1, type = IrisObjectReplace.class)
@@ -189,7 +187,6 @@ public class IrisObjectPlacement {
public IrisObjectPlacement toPlacement(String... place) {
IrisObjectPlacement p = new IrisObjectPlacement();
p.setPlace(new KList<>(place));
p.setTranslateCenter(translateCenter);
p.setMode(mode);
p.setEdit(edit);
p.setTranslate(translate);
@@ -29,6 +29,7 @@ import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.data.B;
import art.arcane.iris.util.common.data.VectorMap;
import art.arcane.iris.util.common.math.IrisBlockVector;
import art.arcane.iris.util.project.noise.CNG;
import art.arcane.iris.util.project.noise.SimplexNoise;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.volmlib.util.collection.KList;
@@ -111,6 +112,13 @@ final class IrisObjectPlacementRunner {
}
boolean warped = !config.getWarp().isFlat();
// Placement-invariant hoists off the per-voxel loop: the warp CNG resolution went
// through a keyed cache probe per block, and the two boolean gates below cut per-block
// property scans that can never match for this placement.
CNG surfaceWarp = warped ? config.getSurfaceWarp(rng, self.getLoader()) : null;
double warpHalf = warped ? config.getWarp().getMultiplier() / 2D : 0D;
boolean preventingDecay = placer.isPreventingDecay();
boolean waterlogCandidate = config.isWaterloggable() || config.isUnderwater();
boolean rawStructurePiece = config.getMode() == ObjectPlaceMode.STRUCTURE_PIECE;
boolean organicFloor = config.getMode() == ObjectPlaceMode.ORGANIC_STILT;
boolean ceilingHang = config.getMode() == ObjectPlaceMode.CEILING_HANG;
@@ -401,16 +409,23 @@ final class IrisObjectPlacementRunner {
markers = new KMap<>();
var list = StreamSupport.stream(blocks.keys().spliterator(), false)
.collect(KList.collector());
// Marker selection persists into the mantle, so it must be seed-deterministic.
// Derive a side stream keyed on the placement position instead of consuming the
// caller's rng: consuming draws there would shift every later placement in the
// chunk and invalidate existing worlds/goldenhashes.
RNG markerRng = rng.nextParallelRNG(((((long) x) << 32) | (z & 0xFFFFFFFFL)) * 31L + yv);
int markerIndex = 0;
for (IrisObjectMarker j : config.getMarkers()) {
IrisMarker marker = self.getLoader().getMarkerLoader().load(j.getMarker());
int markerSalt = markerIndex++;
if (marker == null) {
continue;
}
int max = j.getMaximumMarkers();
for (IrisBlockVector i : list.shuffle()) {
for (IrisBlockVector i : list.shuffleCopy(markerRng.nextParallelRNG(markerSalt))) {
if (max <= 0) {
break;
}
@@ -480,7 +495,7 @@ final class IrisObjectPlacementRunner {
}
}
if (placer.isPreventingDecay() && IrisProceduralBlocks.hasProperty(data, "distance") && "false".equals(IrisProceduralBlocks.propertyValue(data, "persistent"))) {
if (preventingDecay && IrisProceduralBlocks.hasProperty(data, "distance") && "false".equals(IrisProceduralBlocks.propertyValue(data, "persistent"))) {
data = data.withProperty("persistent", "true");
}
@@ -513,8 +528,8 @@ final class IrisObjectPlacementRunner {
zz = z + (int) Math.round(i.getZ());
if (warped) {
xx += config.warp(rng, i.getX() + x, i.getY() + y, i.getZ() + z, self.getLoader());
zz += config.warp(rng, i.getZ() + z, i.getY() + y, i.getX() + x, self.getLoader());
xx += surfaceWarp.fitDouble(-warpHalf, warpHalf, i.getX() + x, i.getY() + y, i.getZ() + z);
zz += surfaceWarp.fitDouble(-warpHalf, warpHalf, i.getZ() + z, i.getY() + y, i.getX() + x);
}
if (yv < 0 && (config.getMode().equals(ObjectPlaceMode.PAINT)) && !B.isVineBlock(data)) {
@@ -537,7 +552,7 @@ final class IrisObjectPlacementRunner {
continue;
}
if (IrisProceduralBlocks.hasProperty(data, "waterlogged") && shouldAutoWaterlogBlock(placer, config, yv, xx, yy, zz)) {
if (waterlogCandidate && IrisProceduralBlocks.hasProperty(data, "waterlogged") && shouldAutoWaterlogBlock(placer, config, yv, xx, yy, zz)) {
data = data.withProperty("waterlogged", "true");
}
@@ -545,8 +560,9 @@ final class IrisObjectPlacementRunner {
data = attachVineFaces(placer, data, xx, yy, zz);
}
PlatformBlockState existingState = placer.get(xx, yy, zz);
boolean wouldReplace = B.isSolid(existingState) && B.isVineBlock(data);
// Short-circuit order matters for cost only: the mantle read is paid solely
// for vine blocks. Both operands are pure, so the value is unchanged.
boolean wouldReplace = B.isVineBlock(data) && B.isSolid(placer.get(xx, yy, zz));
String material = IrisObjectShaping.materialKey(data);
boolean air = material.equals("minecraft:air") || material.equals("minecraft:cave_air");
boolean place = shouldPlaceObjectBlock(rawStructurePiece, air, wouldReplace);
@@ -702,8 +718,8 @@ final class IrisObjectPlacementRunner {
zz = z + (int) Math.round(i.getZ());
if (warped) {
xx += config.warp(rng, i.getX() + x, i.getY() + y, i.getZ() + z, self.getLoader());
zz += config.warp(rng, i.getZ() + z, i.getY() + y, i.getX() + x, self.getLoader());
xx += surfaceWarp.fitDouble(-warpHalf, warpHalf, i.getX() + x, i.getY() + y, i.getZ() + z);
zz += surfaceWarp.fitDouble(-warpHalf, warpHalf, i.getZ() + z, i.getY() + y, i.getX() + x);
}
if (organic) {
@@ -46,6 +46,16 @@ public class IrisObjectScale {
.concurrencyLevel(32)
.build();
/**
* Coarse flush wired into IrisData dump/hotload: keys hold strong IrisObject references
* (and through them the owning IrisData), so without this every hotload or world unload
* pinned the previous pack graph for the process lifetime. Entries are pure derived data,
* so a flushed still-live entry just recomputes.
*/
public static void invalidate() {
cache.clear();
}
@MinNumber(0.01)
@MaxNumber(50)
@Desc("Fixed scale multiplier for this object. 0.5 shrinks to half size, 2.0 doubles the size. When set to anything other than 1, this overrides minimumScale and maximumScale. Leave at 1 to use the minimumScale/maximumScale range.")
@@ -46,6 +46,20 @@ final class IrisObjectShaping {
return;
}
// Whole computation under the object's write lock: two racing callers would otherwise
// both compute, with the loser reading self.blocks while the winner merges into it.
self.writeLock.lock();
try {
if (self.smartBored) {
return;
}
ensureSmartBoredLocked(self, debug);
} finally {
self.writeLock.unlock();
}
}
private static void ensureSmartBoredLocked(IrisObject self, boolean debug) {
PrecisionStopwatch p = PrecisionStopwatch.start();
PlatformBlockState vair = debug ? IrisObject.States.VAIR_DEBUG : IrisObject.States.VAIR;
AtomicInteger applied = new AtomicInteger();
@@ -75,7 +89,12 @@ final class IrisObjectShaping {
}
VectorMap<PlatformBlockState> bore = new VectorMap<>();
// Inline on purpose: the three axis passes read AND write the shared bore map, so the
// bored volume must come from a fixed X->Y->Z order on the calling (lock-owning)
// thread, not from pool scheduling. This matches what burst-worker callers (studio,
// generation threads) already produced, so output is unchanged where it was stable.
BurstExecutor burst = MultiBurst.burst.burst();
burst.setMulticore(false);
// Smash X
for (int rayY = min.getBlockY(); rayY <= max.getBlockY(); rayY++) {
@@ -198,18 +217,25 @@ final class IrisObjectShaping {
max.setZ(Math.max(max.getZ(), i.getZ()));
}
self.w = max.getBlockX() - min.getBlockX() + 1;
self.h = max.getBlockY() - min.getBlockY() + 1;
self.d = max.getBlockZ() - min.getBlockZ() + 1;
self.center = new Vector3i(self.w / 2, self.h / 2, self.d / 2);
// Compute into locals and assign every field in one block at the end, so a reader can
// never observe post-shrink dimensions paired with pre-shrink volume (or vice versa).
int w = max.getBlockX() - min.getBlockX() + 1;
int h = max.getBlockY() - min.getBlockY() + 1;
int d = max.getBlockZ() - min.getBlockZ() + 1;
Vector3i center = new Vector3i(w / 2, h / 2, d / 2);
Vector3i offset = new Vector3i(
-self.center.getBlockX() - min.getBlockX(),
-self.center.getBlockY() - min.getBlockY(),
-self.center.getBlockZ() - min.getBlockZ()
-center.getBlockX() - min.getBlockX(),
-center.getBlockY() - min.getBlockY(),
-center.getBlockZ() - min.getBlockZ()
);
if (offset.getBlockX() == 0 && offset.getBlockY() == 0 && offset.getBlockZ() == 0)
if (offset.getBlockX() == 0 && offset.getBlockY() == 0 && offset.getBlockZ() == 0) {
self.w = w;
self.h = h;
self.d = d;
self.center = center;
return;
}
VectorMap<PlatformBlockState> b = new VectorMap<>();
VectorMap<TileData> s = new VectorMap<>();
@@ -225,10 +251,15 @@ final class IrisObjectShaping {
s.put(vector, data);
});
self.w = w;
self.h = h;
self.d = d;
self.center = center;
self.shrinkOffset = offset;
self.blocks = b;
self.states = s;
self.surfaceSupportOffsets.reset();
self.floatingFootprint.reset();
}
static void clean(IrisObject self) {
@@ -241,6 +272,7 @@ final class IrisObjectShaping {
self.blocks = d;
self.states = dx;
self.surfaceSupportOffsets.reset();
self.floatingFootprint.reset();
}
static boolean shouldStilt(PlatformBlockState state) {
@@ -57,6 +57,7 @@ final class IrisObjectTransforms {
self.states = dx;
IrisObjectShaping.shrinkwrap(self);
self.surfaceSupportOffsets.reset();
self.floatingFootprint.reset();
} finally {
self.writeLock.unlock();
}
@@ -155,6 +156,7 @@ final class IrisObjectTransforms {
self.blocks = b;
self.surfaceSupportOffsets.reset();
self.floatingFootprint.reset();
} finally {
self.writeLock.unlock();
}
@@ -190,6 +192,7 @@ final class IrisObjectTransforms {
self.blocks = b;
self.surfaceSupportOffsets.reset();
self.floatingFootprint.reset();
} finally {
self.writeLock.unlock();
}
@@ -229,6 +232,7 @@ final class IrisObjectTransforms {
self.blocks = b;
self.surfaceSupportOffsets.reset();
self.floatingFootprint.reset();
} finally {
self.writeLock.unlock();
}
@@ -59,14 +59,9 @@ public class IrisRegion extends IrisRegistrant implements IRare {
private final transient AtomicCache<KList<String>> cacheSpot = new AtomicCache<>();
private final transient AtomicCache<CNG> shoreHeightGenerator = new AtomicCache<>();
private final transient AtomicCache<KList<IrisBiome>> realLandBiomes = new AtomicCache<>();
private final transient AtomicCache<KList<IrisBiome>> realLakeBiomes = new AtomicCache<>();
private final transient AtomicCache<KList<IrisBiome>> realRiverBiomes = new AtomicCache<>();
private final transient AtomicCache<KList<IrisBiome>> realSeaBiomes = new AtomicCache<>();
private final transient AtomicCache<KList<IrisBiome>> realShoreBiomes = new AtomicCache<>();
private final transient AtomicCache<KList<IrisBiome>> realCaveBiomes = new AtomicCache<>();
private final transient AtomicCache<CNG> lakeGen = new AtomicCache<>();
private final transient AtomicCache<CNG> riverGen = new AtomicCache<>();
private final transient AtomicCache<CNG> riverChanceGen = new AtomicCache<>();
private final transient AtomicCache<Color> cacheColor = new AtomicCache<>();
private final transient AtomicCache<KList<IrisOreGenerator>> surfaceOreCache = new AtomicCache<>();
private final transient AtomicCache<KList<IrisOreGenerator>> undergroundOreCache = new AtomicCache<>();
@@ -146,10 +141,6 @@ public class IrisRegion extends IrisRegistrant implements IRare {
@ArrayType(min = 1, type = IrisDepositVariant.class)
@Desc("Deposit ore remap rules scoped to this region. Each entry declares a vertical band and a source->replacement block id map. Applied after biome rules but before dimension rules; first matching region rule wins.")
private KList<IrisDepositVariant> depositVariants = new KList<>();
@Desc("Unused. This field is not read by the engine; rivers are not generated from it.")
private IrisGeneratorStyle riverStyle = NoiseStyle.VASCULAR_THIN.style().zoomed(7.77);
@Desc("Unused. This field is not read by the engine; lakes are not generated from it.")
private IrisGeneratorStyle lakeStyle = NoiseStyle.CELLULAR_IRIS_THICK.style();
@Desc("A color for visualizing this region with a color. I.e. #F13AF5. This will show up on the map.")
private String color = null;
@Desc("Collection of ores to be generated")
@@ -276,23 +267,6 @@ public class IrisRegion extends IrisRegistrant implements IRare {
});
}
public double getBiomeZoom(InferredType t) {
switch (t) {
case CAVE:
return caveBiomeZoom;
case LAND:
return landBiomeZoom;
case SEA:
return seaBiomeZoom;
case SHORE:
return shoreBiomeZoom;
default:
break;
}
return 1;
}
public CNG getShoreHeightGenerator() {
return shoreHeightGenerator.aquire(() ->
CNG.signature(new RNG((long) (getName().length() + getLandBiomeZoom() + getLandBiomes().size() + 3458612))));
@@ -41,16 +41,12 @@ public class IrisTree {
@ArrayType(min = 1, type = String.class)
private KList<String> treeTypes = new KList<>();
@Desc("Unused. This flag is not read by the tree matcher; use treeTypes to control which TreeTypes match.")
private boolean anyTree = false;
@Required
@Desc("The size of the square of saplings this applies to (2 means a 2 * 2 sapling area)")
@ArrayType(min = 1, type = IrisTreeSize.class)
private KList<IrisTreeSize> sizes = new KList<>();
@Desc("Unused. This flag is not read by the tree matcher; use sizes to control which sapling sizes match.")
private boolean anySize;
public boolean matches(IrisTreeSize size, TreeType type) {
if (!matchesSize(size)) {
@@ -46,7 +46,7 @@ public enum StudioMode {
@Desc("Debug layout: every biome on a square grid with 36x36-chunk cells per biome, barrier floor past the last biome. Bukkit studio worlds only.")
BIOME_BUFFET_36x36,
@Desc("Not implemented: currently generates exactly like NORMAL.")
@Desc("Deprecated: generates exactly like NORMAL and will be removed in a future release.")
REGION_BUFFET,
@Desc("Replaces terrain with the object studio: a flat polished-deepslate floor laying every pack object out on framed, end-rod-marked grid plinths. Bukkit studio worlds only.")
@@ -160,6 +160,12 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
@EventHandler(priority = EventPriority.LOWEST)
public void onWorldInit(WorldInitEvent event) {
if (!Objects.equals(world.identity(), WorldIdentity.key(event.getWorld()).toString())) return;
if (event.getWorld().getGenerator() != this) {
// A generator leaked by an earlier failed creation of a same-key world; only the
// instance Bukkit actually bound may attach an engine to this world.
BukkitPlatform.volmitPlugin().unregisterListener(this);
return;
}
BukkitPlatform.volmitPlugin().unregisterListener(this);
world.setRawWorldSeed(event.getWorld().getSeed());
if (initialize(event.getWorld())) return;
@@ -306,7 +312,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
public EngineTarget getTarget() {
if (engine != null) return engine.getTarget();
return targetCache.aquire(() -> {
return targetCache.aquireOrThrow(() -> {
IrisData data = IrisData.openRuntime(dataLocation);
data.dump();
data.clearLists();
@@ -425,6 +431,14 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
@Override
public CompletableFuture<Void> closeAsync() {
closing = true;
// Outside the exclusive-control block so every close path detaches the WorldInit
// listener, including a rollback where the world never materialized. Guarded: with no
// hosted plugin (unit tests, teardown) volmitPlugin() throws, and that must not stop
// the close.
try {
BukkitPlatform.volmitPlugin().unregisterListener(this);
} catch (Throwable ignored) {
}
CompletableFuture<Void> future = new CompletableFuture<>();
while (!closeFuture.compareAndSet(null, future)) {
CompletableFuture<Void> existing = closeFuture.get();
@@ -458,6 +472,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
} catch (Throwable throwable) {
future.completeExceptionally(throwable);
closeFuture.compareAndSet(future, null);
// A failed close must stay retryable; leaving closing latched would permanently
// reject generation for a world that may still be loaded.
closing = false;
return future;
}
@@ -467,6 +484,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
} else {
future.completeExceptionally(throwable);
closeFuture.compareAndSet(future, null);
closing = false;
}
});
return future;
@@ -787,7 +805,12 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
lastJigsawStudioRequestId = null;
lastMode = null;
}
StudioMode desired = getEngine().getDimension().getStudioMode();
// Gson nulls unknown enum names, so a pack carrying a removed or typo'd studioMode must not NPE the generator.
// Pack-declared studio modes are studio-only: a shipped pack that forgot to reset
// studioMode must never replace production terrain with a debug generator.
StudioMode desired = studio
? java.util.Optional.ofNullable(getEngine().getDimension().getStudioMode()).orElse(StudioMode.NORMAL)
: StudioMode.NORMAL;
if (studio && art.arcane.iris.core.runtime.ObjectStudioActivation.isActive(getEngine().getDimension().getLoadKey())) {
desired = StudioMode.OBJECT_BUFFET;
}
@@ -34,6 +34,10 @@ import java.util.concurrent.ConcurrentHashMap;
*/
public final class BukkitBlockState implements PlatformBlockState {
private static final ConcurrentHashMap<String, BukkitBlockState> CACHE = new ConcurrentHashMap<>();
// Front cache keyed on the BlockData itself (CraftBlockData equals/hashCode delegate to
// the canonical NMS state): a hit skips getAsString(), which built the full property
// string on EVERY of() call the dominant cost of the 99.9% hit case.
private static final ConcurrentHashMap<BlockData, BukkitBlockState> DATA_CACHE = new ConcurrentHashMap<>();
private final BlockData data;
private final String key;
@@ -68,8 +72,14 @@ public final class BukkitBlockState implements PlatformBlockState {
if (data instanceof IrisCustomData custom) {
return new BukkitBlockState(data, custom.getAsString());
}
BukkitBlockState fast = DATA_CACHE.get(data);
if (fast != null) {
return fast;
}
String key = data.getAsString();
return CACHE.computeIfAbsent(key, (String k) -> new BukkitBlockState(data, k));
BukkitBlockState state = CACHE.computeIfAbsent(key, (String k) -> new BukkitBlockState(data, k));
DATA_CACHE.putIfAbsent(data, state);
return state;
}
@Override
@@ -361,6 +371,12 @@ public final class BukkitBlockState implements PlatformBlockState {
public PlatformBlockState withProperty(String name, String value) {
String merged = mergeProperty(key, name, value);
BlockData resolved = Bukkit.createBlockData(merged);
// Re-attach the custom identity (as the proxy's own merge/clone cases do): the key of
// a custom state is the BASE block's string, so rebuilding from it alone silently
// downgraded custom blocks to vanilla on e.g. auto-waterlogging.
if (data instanceof IrisCustomData custom) {
return of(IrisCustomData.of(resolved, custom.getCustom()));
}
return of(resolved);
}
@@ -20,7 +20,7 @@ public final class IrisStructureHandler implements DirectorParameterHandler<Stri
addStructureKeys(keys, activeData);
}
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(
IrisPlatforms.get().dataFolder("packs"))) {
IrisPlatforms.get().packsFolder())) {
addStructureKeys(keys, IrisData.get(pack));
}
return new KList<>(keys);
@@ -38,7 +38,7 @@ public class ObjectHandler implements DirectorParameterHandler<String> {
}
for (File i : PackDirectoryResolver.listVisiblePackDirectories(
IrisPlatforms.get().dataFolder("packs"))) {
IrisPlatforms.get().packsFolder())) {
data = IrisData.get(i);
p.add(data.getObjectLoader().getPossibleKeys());
}
@@ -42,7 +42,7 @@ public class ObjectTargetHandler implements DirectorParameterHandler<String> {
collectPrefixes(k, prefixes);
}
} else {
File packsFolder = IrisPlatforms.get().dataFolder("packs");
File packsFolder = IrisPlatforms.get().packsFolder();
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) {
IrisData d = IrisData.get(pack);
for (String k : d.getObjectLoader().getPossibleKeys()) {
@@ -36,7 +36,7 @@ public abstract class RegistrantHandler<T extends IrisRegistrant> implements Dir
}
for (File i : PackDirectoryResolver.listVisiblePackDirectories(
IrisPlatforms.get().dataFolder("packs"))) {
IrisPlatforms.get().packsFolder())) {
data = IrisData.get(i);
for (T j : data.getLoader(type).loadAll(data.getLoader(type).getPossibleKeys())) {
if (known.add(j.getLoadKey()))
@@ -154,7 +154,7 @@ public class getHardware {
for (Display display : displays) {
systemEDID.add("Display: " + display.getEdid());
}
if (!systemEDID.isEmpty()) {
if (systemEDID.isEmpty()) {
systemEDID.add("No displays");
}
return systemEDID.copy();
@@ -174,8 +174,8 @@ public class getHardware {
for (InetAddress ia : Collections.list(inetAddresses)) {
interfaces.add("IP: %s", ia.getHostAddress());
}
return interfaces.copy();
}
return interfaces.copy();
} catch (Exception e) {
e.printStackTrace();
}

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