17 KiB
Iris world engine and pregeneration events
Two Bukkit events tell you what Iris is doing over time. IrisWorldEngineEvent marks the points at
which an Iris world's engine becomes usable, is rebuilt under you, or is about to stop being usable.
IrisPregenerationEvent reports the progress of a pregeneration job. Both are pure observation:
neither is cancellable, and nothing you do in a handler changes what Iris does next.
Use IrisWorldEngineEvent instead of WorldLoadEvent if you care about the generator rather than
the world. A world exists before its Iris engine is ready to answer questions, and it still exists
after the engine has been told to close.
Depending on Iris
See README.md for the build and plugin-descriptor setup. Events need
no service lookup — register a Listener in your onEnable as usual and Bukkit unregisters you
when your plugin disables.
Both events have their own HandlerList. There is no shared base class and no common interface;
IrisWorldEngineEvent and IrisPregenerationEvent extend org.bukkit.event.Event directly.
Neither implements Cancellable. ignoreCancelled = true on a handler for either is meaningless
and will not do what you expect.
The world engine lifecycle
public enum IrisWorldPhase {
ENGINE_READY,
ENGINE_HOTLOADED,
ENGINE_CLOSING
}
ENGINE_READY the engine for this world is registered and answering.
| Terrain queries work from here on.
|
+--> ENGINE_HOTLOADED the pack was edited and the engine rebuilt in place.
| Same world, same engine object, different pack contents.
| Can fire any number of times, or never.
|
v
ENGINE_CLOSING the engine is about to be torn down. Last call.
Guarantees Iris makes:
ENGINE_READYfires at most once per world for a given registration. It is keyed on the world's UUID, so a world that unloads and loads again gets a freshENGINE_READY.ENGINE_CLOSINGis never delivered without a precedingENGINE_READYfor that world. If Iris never announced a world ready, it never announces it closing.ENGINE_CLOSINGis dispatched before Iris starts closing the generator, not after. When your handler runs, the engine has not been shut down yet.- If Iris replaces a world's engine — the generator was swapped out and a new one registered — you
get
ENGINE_CLOSINGfor the old one, and a laterENGINE_READYwhen the replacement finishes registering. You never get two consecutiveENGINE_READYwithout aCLOSINGbetween them. - On Iris shutdown, every world that was announced ready is announced closing, before Iris drains its worker pool and before any generator is closed.
ENGINE_HOTLOADEDis not deduplicated and does not participate in the ready/closing pairing. It is a notification that the pack data behind a live engine was reloaded and the engine rebuilt around it. The world, the world object and the seed are unchanged; the pack contents may not be. Treat any pack-derived value you cached atENGINE_READYas stale when it arrives.
The one thing ENGINE_CLOSING does not promise
ENGINE_CLOSING is fired before the generator closes, but during a full plugin shutdown the
terrain service may already have been withdrawn by the time your handler runs — Iris tears down its
services in an unspecified order. So:
Do not treat
ENGINE_CLOSINGas a window in which to run terrain queries. Capture whatever you need atENGINE_READYand useENGINE_CLOSINGonly to drop it.
A terrain query in a closing handler does not throw. It returns absent, which is worse, because it looks like data.
The event
public class IrisWorldEngineEvent extends Event {
public IrisWorldEngineEvent(World world, IrisWorldPhase phase, IrisWorldInfo info);
public static HandlerList getHandlerList();
public World getWorld();
public IrisWorldPhase getPhase();
public Optional<IrisWorldInfo> getInfo();
@Override
public HandlerList getHandlers();
}
getWorld() and getPhase() are never null — the constructor rejects both.
getInfo() is Optional and can be empty. It is empty when Iris could not describe the engine at
dispatch time: the generator was already closing, the engine was already closed, or building the
description threw (which is logged with a stack trace, and does not suppress the event). Handle the
empty case; do not call get() unconditionally.
IrisWorldInfo is documented in terrain.md. The short
version is that it carries the dimension load key, the world's namespaced identity, the seed, the
world height bounds, the pack's sea level, and whether this is a transient studio world.
Threading
Handlers always run on the main thread. On Folia, that is the global region thread.
Iris raises these phases from several places — the world load and unload handlers, its own enable and disable, and a pack hotload that can originate from a file watcher thread. The dispatch normalises all of them:
- Raised from the primary thread: the event is called inline, before the raising code continues.
A
WorldLoadEventhandler of yours that registers state, and anENGINE_READYhandler that reads it, will see a consistent picture. - Raised from any other thread: the event is handed to the server scheduler and delivered on the main or global region thread on a later tick.
So your handler is always on a thread where touching Bukkit is legal, and never on the file-watcher or worker thread that caused the phase.
What is forbidden: blocking. These phases run on the thread the server ticks on. No I/O, no
CompletableFuture#join, no waiting on another scheduler. If you need to persist something, hand it
to your own executor.
Worked example: caching pack metadata per world
A plugin that shows the dimension a player is in wants that string without asking Iris for it on every render. It captures it once when the engine is ready and drops it when the engine closes.
package com.example.hud;
import art.arcane.iris.api.terrain.IrisWorldInfo;
import art.arcane.iris.api.world.IrisWorldEngineEvent;
import art.arcane.iris.api.world.IrisWorldPhase;
import org.bukkit.World;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
public final class IrisWorldRegistry implements Listener {
private final Map<UUID, String> dimensionKeys = new ConcurrentHashMap<>();
public String dimensionKeyOf(World world) {
return dimensionKeys.get(world.getUID());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onEngine(IrisWorldEngineEvent event) {
UUID worldId = event.getWorld().getUID();
switch (event.getPhase()) {
case ENGINE_READY, ENGINE_HOTLOADED -> {
Optional<IrisWorldInfo> info = event.getInfo();
if (info.isEmpty()) {
dimensionKeys.remove(worldId);
return;
}
dimensionKeys.put(worldId, info.get().dimensionKey());
}
case ENGINE_CLOSING -> dimensionKeys.remove(worldId);
default -> {
}
}
}
}
ENGINE_HOTLOADED is handled alongside ENGINE_READY because a hotload can change the pack's
dimension key. The default arm is there because the enum can grow; see
README.md.
The map is a ConcurrentHashMap even though the handler is single-threaded, because
dimensionKeyOf is read from wherever your HUD renders.
Pregeneration
public enum IrisPregenPhase {
STARTED,
TICK,
PAUSED,
RESUMED,
SAVING,
COMPLETED,
CANCELLED
}
public class IrisPregenerationEvent extends Event {
public IrisPregenerationEvent(IrisPregenPhase phase, IrisPregenProgress progress);
public static HandlerList getHandlerList();
public IrisPregenPhase getPhase();
public IrisPregenProgress getProgress();
@Override
public HandlerList getHandlers();
}
Both accessors are never null; the constructor rejects both.
The order phases arrive in
STARTED -> TICK -> TICK -> ... -> COMPLETED
|
+-- PAUSED -> TICK -> ... -> RESUMED -> TICK -> ...
|
+-- SAVING (once, near the end)
|
+-- CANCELLED (instead of COMPLETED, if the job was stopped early)
- One job at a time, server-wide. Iris runs a single pregeneration job per server. There is no
job identifier on the event because there is nothing to disambiguate;
IrisPregenProgressnames the world the running job is working on. STARTEDis dispatched exactly once per job, immediately before that job's firstTICK, in that order.TICKfires once per second while the job runs. It fires while paused too.PAUSEDandRESUMEDfire on the transition only, each immediately followed by aTICK. A job that is never paused never emits either.SAVINGfires at most once per job.- Exactly one of
COMPLETEDorCANCELLEDis dispatched, and it is terminal.COMPLETEDmeans the job reached its chunk total;CANCELLEDmeans it stopped before that, whether by operator action or by shutdown. No phase is ever dispatched for a job after its terminal phase.
Threading
Handlers always run on the main thread. On Folia, that is the global region thread.
The pregenerator ticks on its own worker thread, so every pregeneration phase is scheduled rather than called inline. It arrives on a later tick than the moment the numbers were sampled. For a progress bar this is invisible; for anything that correlates pregeneration against another timeline, assume up to one tick of skew.
Do not block. The job does not wait for your handler — the dispatch is fire-and-forget and a throwing handler is logged and skipped — but you are on the server's tick thread and everything else does wait for you.
What IrisPregenProgress tells you
public record IrisPregenProgress(
String worldName,
String worldIdentity,
double percent,
long generatedChunks,
long totalChunks,
long remainingChunks,
long failedChunks,
double chunksPerSecond,
long etaMillis,
long elapsedMillis,
String method,
boolean paused) {
}
| Component | What it is |
|---|---|
worldName |
Never null; falls back to worldIdentity |
worldIdentity |
The world's namespaced key rendered as a string |
percent |
0.0 to 100.0 |
generatedChunks |
Chunks the job has finished |
totalChunks |
Chunks in the job |
remainingChunks |
Chunks still to do |
failedChunks |
Chunks the job could not generate |
chunksPerSecond |
Current rate |
etaMillis |
Estimated milliseconds remaining |
elapsedMillis |
Milliseconds since the job started |
method |
Never null; "" when unknown |
paused |
true while the job is paused |
The record's constructor sanitises everything before you see it, so you never have to defend against the generator's arithmetic:
percentis clamped to0.0 .. 100.0.NaNand infinity become0.0.chunksPerSecondis clamped to at least0.0.NaNand infinity become0.0.generatedChunks,totalChunks,remainingChunks,failedChunks,etaMillisandelapsedMillisare clamped to at least0.worldNamefalls back toworldIdentitywhen the world has no name.methodbecomes""rather thannull.
The only rejection is a null worldIdentity, which throws NullPointerException at construction —
so an instance delivered to you always identifies a world.
etaMillis is an estimate derived from the running rate and is 0 before enough chunks have
completed to compute one. failedChunks counts chunks the job could not generate; a non-zero value
on COMPLETED means the job finished with holes.
Worked example: mirroring pregeneration into a boss bar
package com.example.pregenbar;
import art.arcane.iris.api.pregen.IrisPregenProgress;
import art.arcane.iris.api.pregen.IrisPregenerationEvent;
import org.bukkit.Bukkit;
import org.bukkit.boss.BarColor;
import org.bukkit.boss.BarStyle;
import org.bukkit.boss.BossBar;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
public final class PregenBar implements Listener {
private BossBar bar;
@EventHandler(priority = EventPriority.MONITOR)
public void onPregen(IrisPregenerationEvent event) {
IrisPregenProgress progress = event.getProgress();
switch (event.getPhase()) {
case STARTED -> open(progress);
case TICK, PAUSED, RESUMED, SAVING -> update(progress);
case COMPLETED, CANCELLED -> close();
default -> {
}
}
}
private void open(IrisPregenProgress progress) {
close();
bar = Bukkit.createBossBar(
"Pregenerating " + progress.worldName(), BarColor.BLUE, BarStyle.SEGMENTED_10);
for (Player player : Bukkit.getOnlinePlayers()) {
bar.addPlayer(player);
}
update(progress);
}
private void update(IrisPregenProgress progress) {
if (bar == null) {
return;
}
bar.setProgress(progress.percent() / 100.0D);
bar.setColor(progress.paused() ? BarColor.YELLOW : BarColor.BLUE);
bar.setTitle(progress.worldName()
+ " " + progress.generatedChunks() + "/" + progress.totalChunks()
+ " at " + Math.round(progress.chunksPerSecond()) + "/s");
}
private void close() {
if (bar == null) {
return;
}
bar.removeAll();
bar = null;
}
}
bar needs no synchronisation: every phase is delivered on the same thread.
percent() is already clamped, so dividing by 100 always yields a legal boss-bar progress value.
The minimum: knowing a world is usable
If all you want is "run this once, when Iris can answer for this world":
@EventHandler
public void onEngine(IrisWorldEngineEvent event) {
if (event.getPhase() == IrisWorldPhase.ENGINE_READY) {
prepare(event.getWorld());
}
}
No switch, no Optional, no service lookup. Do not add ignoreCancelled = true; the event is not
cancellable.
Failure policy
| Situation | What Iris does |
|---|---|
| Your handler throws | Logged with the stack trace. The remaining handlers still run, and Iris's own lifecycle continues unaffected |
| Iris cannot describe a world for a phase | The failure is logged and the event is still delivered, with getInfo() empty |
| The event dispatch itself throws | Logged, naming the phase and world. The engine registration or teardown that raised it proceeds |
| The pregeneration sink is not registered | No IrisPregenerationEvent is fired at all. This is the state before Iris finishes enabling and after it starts disabling |
| A pregeneration handler throws | Logged, naming the phase. The job is not slowed, paused or stopped |
| Iris shuts down mid-pregeneration | The job's terminal phase is CANCELLED |
| Iris shuts down with worlds registered | Every announced world receives ENGINE_CLOSING before the worker pool drains |
Iris does not quarantine a listener. A handler that throws on every event will be logged on every event, forever. There is no fault limit and no automatic unregistration.
Iris never suppresses a lifecycle phase because a third party misbehaved. A logged failure is always accompanied by delivery, or by the lifecycle step proceeding without delivery — never by a silent stall.
Configuration
There are no configuration keys for either event. They are always on when Iris is enabled, cannot be disabled, and have no per-world gate.
Enum reference
IrisWorldPhase
| Constant | Meaning | Fires |
|---|---|---|
ENGINE_READY |
The engine is registered and answering queries | Once per world registration |
ENGINE_HOTLOADED |
A live engine's pack data was reloaded in place | Any number of times, or never. It is dispatched straight from the hotload, not through the ready/closing bookkeeping, so it is not paired with either |
ENGINE_CLOSING |
The engine is about to be torn down | Once per world registration, always after a READY |
IrisPregenPhase
| Constant | Meaning | Fires |
|---|---|---|
STARTED |
A job began | Once per job, immediately before its first TICK |
TICK |
Periodic progress sample | Once per second while the job exists, including while paused |
PAUSED |
The job was paused | On the transition only, followed by a TICK |
RESUMED |
The job was resumed | On the transition only, followed by a TICK |
SAVING |
The job is flushing to disk | At most once per job |
COMPLETED |
The job reached its chunk total | Terminal; mutually exclusive with CANCELLED |
CANCELLED |
The job stopped before its total | Terminal; mutually exclusive with COMPLETED |
Write a default arm when switching over either; see
README.md.