13 KiB
92 - API - World Events
IrisWorldEngineEvent reports when an Iris world's engine becomes usable, is rebuilt under you, or is about to stop being usable. IrisPregenerationEvent reports pregeneration job progress. Both are pure observation: not cancellable, and handlers cannot change Iris's next step. Prefer IrisWorldEngineEvent over WorldLoadEvent when you care about the generator: a world exists before its Iris engine can answer, and still exists after the engine is told to close.
Reach for these when your plugin has per-world setup that must happen exactly when Iris can answer for that world — caching the dimension key, building a map layer, warming a spawn candidate list — or when you want to mirror pregeneration progress somewhere Iris does not draw it (a boss bar, a web panel, a Discord relay).
Build setup: 90 - API - Getting Started.md. No service lookup — register a Listener in onEnable; Bukkit unregisters on your disable.
Each event has its own HandlerList. No shared base class. Neither implements Cancellable; ignoreCancelled = true does nothing useful.
World engine lifecycle
public enum IrisWorldPhase {
ENGINE_READY,
ENGINE_HOTLOADED,
ENGINE_CLOSING
}
ENGINE_READY engine registered and answering; terrain queries work from here
|
+--> ENGINE_HOTLOADED pack reloaded; same world/engine object, pack contents may change
| any number of times, or never
v
ENGINE_CLOSING engine about to tear down; last call
Guarantees:
ENGINE_READYfires at most once per world registration, keyed on world UUID. Unload + load again gets a new ready.ENGINE_CLOSINGis never delivered without a priorENGINE_READYfor that world (ledger-gated).ENGINE_CLOSINGis dispatched before Iris starts closing the generator.- Engine replacement:
ENGINE_CLOSINGfor the old, laterENGINE_READYfor the new — never two consecutive ready without closing between them. - On Iris shutdown, every announced-ready world gets closing before worker pool drain and generator close.
ENGINE_HOTLOADEDis not deduplicated and is not part of ready/closing pairing. Treat pack-derived caches fromENGINE_READYas stale when it arrives.
What ENGINE_CLOSING does not promise
Closing fires before the generator closes, but during full plugin shutdown the terrain service may already be withdrawn. Do not run terrain queries in a closing handler. Capture state at ENGINE_READY; use closing only to drop it. Queries during closing return absent without throwing.
Closing is also not a crash guarantee. It is delivered on the normal plugin-disable path, where teardown runs on the main thread and the event is called inline. If the JVM exits without a clean plugin disable, Iris's shutdown hook still parks the generators, but nothing dispatches to your listener — persist anything you cannot rebuild as you go, not at closing.
The world engine 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 (constructor rejects nulls).
getInfo() may be empty if Iris could not describe the engine at dispatch (generator already closing, engine closed, or describe threw — logged; event still delivered). Do not call Optional#get() unconditionally.
IrisWorldInfo fields: 91 - API - Terrain.md.
Threading
Handlers always run on the main thread. On Folia, that is the global region thread.
Dispatch:
- Raised on the primary thread: event called inline before the raiser continues.
- Raised off-thread (e.g. file-watcher hotload): scheduled to main/global region on a later tick via Iris's event path.
Blocking is forbidden on this thread: no I/O, no CompletableFuture#join, no waiting on another scheduler.
Worked example: cache pack metadata per world
package com.example.hud;
import art.arcane.iris.api.terrain.IrisWorldInfo;
import art.arcane.iris.api.world.IrisWorldEngineEvent;
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 -> {
}
}
}
}
Register it from your onEnable:
getServer().getPluginManager().registerEvents(new IrisWorldRegistry(), this);
default is required because enums can grow: 90 - API - Getting Started.md. Map is concurrent because readers may be off the event thread.
Iris also fires the internal art.arcane.iris.core.events.IrisEngineHotloadEvent alongside ENGINE_HOTLOADED. It exposes the internal Engine type, is not covered by the API purity test, and can change without notice — listen for IrisWorldEngineEvent instead.
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 never null; constructor rejects nulls.
Phase order
STARTED -> TICK -> TICK -> ... -> COMPLETED
|
+-- PAUSED -> TICK -> ... -> RESUMED -> TICK -> ...
|
+-- SAVING (at most once, near end)
|
+-- CANCELLED (instead of COMPLETED if stopped early)
- One job at a time, server-wide. No job id on the event;
IrisPregenProgressnames the world. STARTEDonce per job, immediately before firstTICK.TICKonce per second while the job exists, including while paused.PAUSED/RESUMEDon transition only, each followed by aTICK.SAVINGat most once per job.- Exactly one of
COMPLETEDorCANCELLEDis terminal. No phase after the terminal.
Threading
Handlers always run on the main / Folia global region thread. Pregen ticks on a worker; phases are scheduled (up to about one tick of skew). Fire-and-forget: a throwing handler is logged and skipped; the job does not wait. Do not block the tick thread.
IrisPregenProgress
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 | Meaning |
|---|---|
worldName |
Never null; falls back to worldIdentity |
worldIdentity |
World's namespaced key string |
percent |
0.0 .. 100.0 |
generatedChunks |
Finished chunks |
totalChunks |
Job total |
remainingChunks |
Still to do |
failedChunks |
Could not generate |
chunksPerSecond |
Current rate |
etaMillis |
Estimated remaining ms |
elapsedMillis |
Since job start |
method |
Never null; "" if unknown |
paused |
Job paused |
Constructor sanitises:
percentclamped to0..100; non-finite →0chunksPerSecond≥ 0; non-finite →0- chunk and time counters ≥ 0
- null
worldName→worldIdentity; nullmethod→"" - null
worldIdentitythrowsNullPointerExceptionat construction — delivered instances always identify a world
etaMillis is 0 early in a job: below 1024 generated chunks it needs a non-zero rolling chunks/second average, and above that it extrapolates from elapsed time per generated chunk. Non-zero failedChunks on COMPLETED means holes remain.
Operator pregen surface: 07 - Pregeneration.md.
Worked example: 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;
}
}
The minimum: world usable once
@EventHandler
public void onEngine(IrisWorldEngineEvent event) {
if (event.getPhase() == IrisWorldPhase.ENGINE_READY) {
prepare(event.getWorld());
}
}
Do not set ignoreCancelled = true.
Failure policy
| Situation | Behaviour |
|---|---|
| Your handler throws | Logged; remaining handlers run; Iris lifecycle continues |
| Iris cannot describe world for a phase | Logged; event still delivered with empty getInfo() |
| Event dispatch itself throws | Logged with phase and world; registration/teardown proceeds |
| Pregen sink not registered | No IrisPregenerationEvent (before enable completes / after disable starts) |
| Pregen job has no bound world | No event for any phase of that job; a null worldIdentity is dropped at the source |
| Pregen handler throws | Logged; job not slowed/paused/stopped |
| Iris shuts down mid-pregen | Terminal phase is CANCELLED, but delivery is not guaranteed — the event is scheduled onto the main thread and sync scheduling refuses once the plugin is disabled |
| Iris shuts down with worlds registered | Every announced world gets ENGINE_CLOSING before worker drain, on the clean disable path |
No listener quarantine. Iris never silently stalls a lifecycle step because a third party failed.
Configuration
No configuration keys. Events are always on while Iris is enabled; no per-world gate.
Enum reference
IrisWorldPhase
| Constant | Meaning | Fires |
|---|---|---|
ENGINE_READY |
Engine registered and answering | Once per world registration |
ENGINE_HOTLOADED |
Pack data reloaded in place | Any number of times, or never; not ledger-paired |
ENGINE_CLOSING |
Engine about to tear down | Once per registration, always after a ready |
IrisPregenPhase
| Constant | Meaning | Fires |
|---|---|---|
STARTED |
Job began | Once, before first TICK |
TICK |
Progress sample | Once per second while job exists |
PAUSED |
Job paused | Transition only + following TICK |
RESUMED |
Job resumed | Transition only + following TICK |
SAVING |
Flushing to disk | At most once |
COMPLETED |
Reached chunk total | Terminal; exclusive with CANCELLED |
CANCELLED |
Stopped before total | Terminal; exclusive with COMPLETED |
Default arms: 90 - API - Getting Started.md.