Files
Iris/docs/api/world-events.md
T
Brian Neumann-Fopiano d5a55ccfcf API
2026-07-26 12:12:49 -05:00

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_READY fires 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 fresh ENGINE_READY.
  • ENGINE_CLOSING is never delivered without a preceding ENGINE_READY for that world. If Iris never announced a world ready, it never announces it closing.
  • ENGINE_CLOSING is 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_CLOSING for the old one, and a later ENGINE_READY when the replacement finishes registering. You never get two consecutive ENGINE_READY without a CLOSING between 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_HOTLOADED is 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 at ENGINE_READY as 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_CLOSING as a window in which to run terrain queries. Capture whatever you need at ENGINE_READY and use ENGINE_CLOSING only 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 WorldLoadEvent handler of yours that registers state, and an ENGINE_READY handler 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; IrisPregenProgress names the world the running job is working on.
  • STARTED is dispatched exactly once per job, immediately before that job's first TICK, in that order.
  • TICK fires once per second while the job runs. It fires while paused too.
  • PAUSED and RESUMED fire on the transition only, each immediately followed by a TICK. A job that is never paused never emits either.
  • SAVING fires at most once per job.
  • Exactly one of COMPLETED or CANCELLED is dispatched, and it is terminal. COMPLETED means the job reached its chunk total; CANCELLED means 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:

  • percent is clamped to 0.0 .. 100.0. NaN and infinity become 0.0.
  • chunksPerSecond is clamped to at least 0.0. NaN and infinity become 0.0.
  • generatedChunks, totalChunks, remainingChunks, failedChunks, etaMillis and elapsedMillis are clamped to at least 0.
  • worldName falls back to worldIdentity when the world has no name.
  • method becomes "" rather than null.

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.