Files
Iris/docs/92 - API - World Events.md
T
Brian Neumann-Fopiano 365205ad0a d
2026-08-12 13:52:16 -04:00

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_READY fires at most once per world registration, keyed on world UUID. Unload + load again gets a new ready.
  • ENGINE_CLOSING is never delivered without a prior ENGINE_READY for that world (ledger-gated).
  • ENGINE_CLOSING is dispatched before Iris starts closing the generator.
  • Engine replacement: ENGINE_CLOSING for the old, later ENGINE_READY for 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_HOTLOADED is not deduplicated and is not part of ready/closing pairing. Treat pack-derived caches from ENGINE_READY as 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; IrisPregenProgress names the world.
  • STARTED once per job, immediately before first TICK.
  • TICK once per second while the job exists, including while paused.
  • PAUSED / RESUMED on transition only, each followed by a TICK.
  • SAVING at most once per job.
  • Exactly one of COMPLETED or CANCELLED is 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:

  • percent clamped to 0..100; non-finite → 0
  • chunksPerSecond ≥ 0; non-finite → 0
  • chunk and time counters ≥ 0
  • null worldNameworldIdentity; null method""
  • null worldIdentity throws NullPointerException at 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.