This commit is contained in:
Brian Neumann-Fopiano
2026-07-26 12:12:49 -05:00
parent de25b7d7ab
commit d5a55ccfcf
66 changed files with 6300 additions and 424 deletions
+196
View File
@@ -0,0 +1,196 @@
# Iris API
`art.arcane.iris.api` is the surface another plugin compiles against. It answers three questions:
what does Iris terrain look like at a coordinate, when does an Iris world engine come up and go
down, and how do I hand an axe-swing to the Iris tree feller and get told what it cost. It is built
from Bukkit types, `java.*` types and its own types only — no VolmLib, no Adventure, no shaded
types — so it links against a plain Spigot or Paper compile classpath. A test in the Iris build
walks every class in the package and fails the build if any exported signature mentions anything
else.
| Package | What it is for | Document |
|---|---|---|
| `art.arcane.iris.api.terrain` | Ask what the generator says about a coordinate: is this an Iris world, what biome, what region, how high is the surface, what kind of surface | [terrain.md](terrain.md) |
| `art.arcane.iris.api.world` | Learn when an engine becomes usable and when it stops being usable | [world-events.md](world-events.md) |
| `art.arcane.iris.api.pregen` | Follow a pregeneration job | [world-events.md](world-events.md) |
| `art.arcane.iris.api.tree` | Drive the tree feller and charge for it | [tree-feller.md](tree-feller.md) |
PlaceholderAPI keys are not a compile surface, but they are a contract an operator depends on:
[placeholders.md](placeholders.md).
Anything outside `art.arcane.iris.api` is internal. `art.arcane.iris.core.*`,
`art.arcane.iris.engine.*`, `art.arcane.iris.util.*` and `art.arcane.iris.spi.*` change without
notice and without a deprecation cycle. If you find yourself importing `Engine`, `IrisBiome` or
`IrisToolbelt`, you are outside the contract.
---
## Platform limitation
`art.arcane.iris.api` ships in the **Bukkit plugin jar only**. The Fabric, Forge and NeoForge mod
jars contain the same generator but not this package — there is no Bukkit `World`, no
`ServicesManager` and no `Event` bus to hang it on. A mod that wants generator data uses the mod
loader's own registries.
The mod jars carry a separate, unrelated surface at `art.arcane.iris.modded.api`, for supplying
custom block data to the generator from a mod. It is not covered by these documents, is absent from
the Bukkit plugin jar, and shares no types with `art.arcane.iris.api`.
Everything in these documents assumes Paper, Purpur, Leaf, Canvas, Folia or Spigot, Minecraft 26.2,
Java 25.
---
## Depending on Iris
Iris is not published to Maven Central. Two routes work.
**Against the jar you already have.** This is the route that cannot go wrong: the jar you compile
against is the jar you run against.
```gradle
dependencies {
compileOnly(files('libs/Iris.jar'))
}
```
**Against JitPack.** This is what Volmit's own plugins do. `transitive = false` is required — the
Iris build declares a large dependency graph you do not want on your compile classpath.
```gradle
repositories {
maven { url = uri('https://jitpack.io') }
}
dependencies {
compileOnly('com.github.VolmitSoftware:Iris:<tag-or-branch-SNAPSHOT>') {
changing = true
transitive = false
}
}
```
Bukkit plugin (`plugin.yml`):
```yaml
softdepend: [Iris]
```
Paper plugin (`paper-plugin.yml`):
```yaml
dependencies:
server:
Iris:
load: BEFORE
required: false
join-classpath: true
```
`join-classpath: true` is mandatory on Paper. Plugin classloaders are isolated, and without it you
get `NoClassDefFoundError` on `art.arcane.iris.api.*` even though the classes ship unrelocated.
Iris declares `load: STARTUP` and registers its services during its own `onEnable`. Do not resolve
an Iris service in a static initialiser or a constructor. Resolve it lazily, at the point of use,
and handle `null` — see below.
---
## Acquiring a service
Two services are registered with the Bukkit `ServicesManager` at `ServicePriority.Normal`:
`IrisTerrainService` and `IrisTreeFellerService`. Both are unregistered on Iris shutdown.
```java
package com.example.integration;
import art.arcane.iris.api.terrain.IrisTerrainService;
import org.bukkit.Bukkit;
import org.bukkit.plugin.RegisteredServiceProvider;
public final class IrisLookup {
private IrisLookup() {
}
public static IrisTerrainService terrain() {
RegisteredServiceProvider<IrisTerrainService> provider =
Bukkit.getServicesManager().getRegistration(IrisTerrainService.class);
return provider == null ? null : provider.getProvider();
}
}
```
Resolve on every use, or cache and invalidate on `PluginDisableEvent`. A cached reference to a
service whose plugin has been disabled does not throw — every terrain query answers "absent" and
every tree-feller call returns `false` — but it will never answer usefully again, and the
replacement instance registered by a later enable is a different object.
Neither service is a functional interface and neither is meant to be implemented by a third party.
`ServicesManager#getRegistration` hands back the highest-priority registration, so registering your
own `IrisTerrainService` above `Normal` shadows Iris's for every other plugin on the server. Do not.
It does not shadow it for Iris — Iris resolves its own services from an internal registry, so its
PlaceholderAPI expansion keeps reading the real one, and the two would then disagree.
---
## The shared library is not relocated
Iris bundles `art.arcane.volmlib` **unrelocated**, at its real package name. Several sibling Volmit
plugins do relocate it — Adapt shades it to `art.arcane.adapt.util.arcane.volmlib`, React to
`art.arcane.react.util.arcane.volmlib`. Three consequences, in order of how likely they are to bite:
1. **You do not need VolmLib to use this API.** No type in `art.arcane.iris.api` mentions it. You
never import it, never shade it, never declare it.
2. **If you also use VolmLib yourself, shade and relocate your own copy.** Do not compile against
`art.arcane.volmlib` expecting Iris's copy to satisfy it at runtime. Under Paper's isolated
classloaders you would need `join-classpath: true` on the Iris dependency and you would be
binding to whatever VolmLib version Iris happens to ship, which changes on Iris's release
schedule and not yours. Relocating your copy costs nothing and removes the coupling entirely.
3. **A relocated sibling and Iris do not share those classes.** `art.arcane.adapt.util.arcane.volmlib.X`
and `art.arcane.volmlib.X` are unrelated types to the JVM. Never pass an object obtained from one
plugin's shaded copy into another's; the cast fails at runtime, not at compile time.
---
## Threading, at a glance
This suite runs on Folia, where region threads own chunks and entity schedulers own entities. Each
document states its own contract; this is the summary.
| Call | Which thread may call it | Where the callback lands |
|---|---|---|
| Every `IrisTerrainService` read | Any thread, including async | Returns inline |
| `IrisColumnSink.accept` | — | The thread that called `sampleColumns` |
| `IrisTreeFellerService.tryFell` | The region thread delivering the `BlockBreakEvent` | Returns inline |
| `IrisTreeFellerService.isManagedBreak` | Any thread | Returns inline |
| `IrisTreeFellerService.isTreeBlock` | The region thread owning the block; it can also block on disk — see [tree-feller.md](tree-feller.md#istreeblock-is-the-expensive-one) | Returns inline |
| `TreeFellerRunHooks.onActivationAccepted` | — | The region thread that owns the broken block |
| `TreeFellerRunHooks.reserveLogCost` / `commitLogCost` / `refundLogCost` | — | The feller's entity scheduler thread |
| `IrisWorldEngineEvent` handlers | — | Main thread; on Folia, the global region thread |
| `IrisPregenerationEvent` handlers | — | Main thread; on Folia, the global region thread |
"Any thread" is claimed for the terrain reads because they are justified in doing so: they read the
world's generator reference and evaluate cached procedural noise, and touch no chunk, no block
state, no entity and no mantle storage. See [terrain.md](terrain.md#threading) for the full
argument. It is not a claim any other part of this API makes.
---
## Switching over the enums
`IrisSurfaceKind`, `IrisColumnField`, `IrisWorldPhase`, `IrisPregenPhase` and `TreeFellerAccess` may
gain constants in a future release. A `switch` **expression** over them is exhaustive, so it stops
compiling — and throws `IncompatibleClassChangeError` on an already-compiled jar — the moment one is
added.
**Always write a `default` arm** in third-party code:
```java
String label = switch (kind) {
case LAND -> "land";
case OCEAN -> "water";
default -> "";
};
```
+216
View File
@@ -0,0 +1,216 @@
# Iris placeholders
Iris registers the `iris` PlaceholderAPI expansion when PlaceholderAPI is enabled. It publishes
sixteen keys: seven in the world family, describing the generator around the reading player, and
nine in the pregeneration family, describing the server's running pregeneration job.
This is an operator-facing contract, not a compile surface. Nothing here needs a dependency, a
`softdepend`, or a line of Java. If you are writing a plugin rather than a scoreboard, the same data
is available with more precision through the [terrain API](terrain.md) and the
[pregeneration events](world-events.md).
**The pre-2.0 underscore keys are gone.** There is no alias and no dual-accept window. If you are
upgrading an existing board, go straight to the [migration table](#migration-from-the-pre-20-keys).
---
## The value grammar
Every key follows the same rules, so a board never has to special-case Iris:
- Paths are **dot-separated and lowercase** and never contain an underscore. Iris lowercases the
path before resolving it, so `%iris_WORLD.BIOME%` works, but write it lowercase.
- Values are **plain text**: no colour codes, no unit suffixes, no `%` character, `.` as the decimal
separator, no thousands grouping.
- Any section sign or `%` character that appears inside a pack-authored name — a biome display name,
a world name — is stripped before you see it, so a pack cannot inject formatting or a nested
placeholder into your board.
There are exactly three possible answers:
| Answer | When | What PlaceholderAPI shows |
|---|---|---|
| A value | The key is known and has data | The value |
| `---` | The key is known and has no data right now | `---` |
| Nothing | The key is not one Iris publishes | The literal `%iris_...%` |
The third row is deliberate. A typo stays visible on the board instead of quietly rendering as
blank, which is why there is no catch-all fallback.
A real zero is `0`, never `---`. `---` means "no reading", not "zero".
---
## World keys
| Placeholder | Value |
|---|---|
| `%iris_available%` | `true` when the Iris terrain service is live, `false` otherwise |
| `%iris_world.available%` | `true` when the reading player is in an Iris world and a reading exists |
| `%iris_world.biome%` | Surface biome display name at the player, for example `Hot Desert Dunes` |
| `%iris_world.biome-key%` | Surface biome load key, for example `desert/hot-dunes` |
| `%iris_world.region%` | Region display name at the player |
| `%iris_world.region-key%` | Region load key |
| `%iris_world.dimension%` | Dimension (pack) load key of the player's world |
`%iris_available%` is the only world-family key that does not need a player. It answers for the
console and for an offline player.
Every other `world.*` key needs a tracked online player. For the console, an offline player, or a
player Iris has no position for yet, `world.available` is `false` and the rest are `---`.
### They are surface readings, and they are cached
`world.biome`, `world.biome-key`, `world.region` and `world.region-key` describe the **surface** at
the player's block column — the biome and region the generator places at ground level. A player
standing in a cave under an overhang reads the biome of the sky above them, not the cave they are
in. That is what a board reader means by "what biome am I in".
The reading is rebuilt at most **once per second per player**, and only when something actually reads
one of these keys. Consequences:
- A whole board of `world.*` keys costs one rebuild per player per second, however many of them are
on it.
- A value can lag a sprinting player by up to a second.
- A board that nobody is looking at costs nothing.
Position tracking has two speeds. Walking republishes a player's column at most once per second, and
not at all while they stand still. Anything that is not walking — joining, respawning, changing
world, stepping through a portal, or **any** teleport including `/iris goto`, `/tp`, an ender pearl
and a random teleport — publishes immediately. A player who arrives somewhere and stops moving
therefore never keeps showing the biome of where they came from.
---
## Pregeneration keys
| Placeholder | Value |
|---|---|
| `%iris_pregen.available%` | `true` while a pregeneration job is running |
| `%iris_pregen.world%` | World name the running job is pregenerating |
| `%iris_pregen.percent%` | Completion, `0.00` to `100.00`, with no `%` character |
| `%iris_pregen.eta%` | Estimated seconds remaining, whole number |
| `%iris_pregen.eta-text%` | The same estimate as `45s`, `2m 5s` or `1h 30m` |
| `%iris_pregen.chunks%` | Chunks generated so far |
| `%iris_pregen.total%` | Chunks in the job |
| `%iris_pregen.chunks-per-second%` | Current rate, two decimal places |
| `%iris_pregen.paused%` | `true` while the job is paused |
`pregen.*` is **global**, not per player. Iris runs one pregeneration job per server, so these keys
read the same for everyone, including the console. `%iris_pregen.world%` says which world it is.
The snapshot is published when the job reports progress, once per second, and cleared the moment the
job completes or is cancelled. After that every `pregen.*` key except `pregen.available` reads `---`,
and `pregen.available` reads `false`. There is no lingering "last job" state to mistake for a running
one.
`pregen.eta` and `pregen.eta-text` are two renderings of the same estimate: use `eta` for arithmetic
and `eta-text` for display. Both read `0` and `0s` respectively before the job has generated enough
chunks to estimate from.
---
## Availability
The expansion is registered only if PlaceholderAPI is enabled when Iris starts. It sets `persist()`,
so it survives `/papi reload` without Iris restarting.
`%iris_available%` distinguishes "Iris is installed but its terrain service is not up" from "Iris is
not installed at all" — in the second case the expansion does not exist, no key resolves, and every
`%iris_...%` on the board renders literally. Gate a conditional board on `%iris_available%` if you
want it to disappear cleanly rather than show `---` rows on a server where Iris is present but still
starting.
Iris never gates a placeholder on a permission. A placeholder has no permission context to check
against — the player reading a scoreboard is not necessarily the player the value describes — so
values that should not be public are not published at all. That is why there is no seed key.
---
## Migration from the pre-2.0 keys
The old underscore keys are gone. There is no alias and no dual-accept window: an old key now
renders literally, so it is visible rather than silently wrong. This table is complete — every key
the old expansion published appears in it.
| Old key | New key | Why |
|---|---|---|
| `%iris_biome_name%` | `%iris_world.biome%` | Renamed onto the dot grammar |
| `%iris_biome_id%` | `%iris_world.biome-key%` | Renamed; `id` was always the load key |
| `%iris_region_name%` | `%iris_world.region%` | Renamed onto the dot grammar |
| `%iris_region_id%` | `%iris_world.region-key%` | Renamed; `id` was always the load key |
| `%iris_biome_file%` | removed | Rendered an absolute server path into player-visible text, and threw on packs with no backing file |
| `%iris_region_file%` | removed | Same as `biome_file` |
| `%iris_world_seed%` | removed | Handed the world seed to anyone who could read a scoreboard, and a placeholder has no permission context to gate on |
| `%iris_terrain_height%` | removed | Reported the *generated* height, before objects and player edits, so it disagreed with the block under the player's feet |
| `%iris_terrain_slope%` | removed | Three extra noise samples per read for an unformatted pack-authoring diagnostic |
| `%iris_world_mode%` | removed | Studio or Production; a studio world exists for seconds during authoring and is never on a live board |
| `%iris_world_speed%` | removed | Mutated engine rate-window state every time it was read. `%iris_pregen.chunks-per-second%` answers the same question from a snapshot |
There is one behaviour change inside the four renames, and it will be visible on a board that has
been in service for a while. The old keys sampled **two blocks above the player's feet** and asked
for the biome at that exact Y, which meant a player standing under an overhang or inside a cave read
the *cave* biome. `%iris_world.biome%` and `%iris_world.biome-key%` are always the surface biome for
the column. If your board is checked against a screenshot from before the rename, expect
underground readings to differ.
`%iris_world.dimension%` is new. It has no pre-2.0 equivalent.
The three removals worth a replacement plan:
- **`world_seed`** has no replacement and will not get one. A plugin that legitimately needs the seed
can read it from `IrisWorldInfo.seed()` through the [terrain API](terrain.md), where there is a
caller to hold responsible.
- **`terrain_height`** has no replacement. If you want the ground height for a coordinate, use
`IrisTerrainService#surfaceHeight`, which is the same number with its limitations documented. If
you want the block under the player, use the player's own Y.
- **`world_speed`** is replaced by `%iris_pregen.chunks-per-second%` for the pregeneration case,
which is what it was almost always used for. There is no per-world live generation rate key.
---
## Failure policy
| Situation | What Iris shows |
|---|---|
| An unknown path | Nothing. PlaceholderAPI re-emits the literal `%iris_...%` |
| A known path with no data | `---` |
| No player context, on a `world.*` key | `---`, and `world.available` is `false` |
| Player is not in an Iris world | `---`, and `world.available` is `false` |
| The terrain service is not registered | `---`, `world.available` is `false`, `%iris_available%` is `false` |
| No pregeneration job running | `---`, and `pregen.available` is `false` |
| A resolver throws | `---`, and one warning is logged naming the exact placeholder |
A resolver that throws is logged **once per distinct path**, up to 64 distinct paths, so a broken
key cannot flood the console from a scoreboard that re-renders every tick. The value shown is always
`---` — a failure never renders a stack trace, a class name, or an empty string.
Iris does not disable a placeholder after repeated failures. There is no fault limit and no
quarantine; a key that fails keeps being asked and keeps answering `---`.
---
## Key reference
The full published list, as PlaceholderAPI reports it under `/papi info iris`:
```
available
pregen.available
pregen.chunks
pregen.chunks-per-second
pregen.eta
pregen.eta-text
pregen.paused
pregen.percent
pregen.total
pregen.world
world.available
world.biome
world.biome-key
world.dimension
world.region
world.region-key
```
Prefix each with `%iris_` and suffix with `%`.
+584
View File
@@ -0,0 +1,584 @@
# Iris terrain query API
`art.arcane.iris.api.terrain` answers what the Iris generator says about a coordinate: whether a
world is an Iris world at all, what biome and region the pack places there, how high the terrain
generates and whether that surface is land, shore, ocean or nothing. It is a read of the
**generator**, not of the world. It never loads a chunk, never forces generation, never reads a
placed block, and never tells you what a player has since built.
Everything here is cheap and non-blocking, and this document says exactly how cheap and exactly why
non-blocking, because a terrain API where the reader has to guess is a terrain API that ends up in a
per-tick loop.
---
## Depending on Iris and acquiring the service
See [README.md](README.md#depending-on-iris) for the build and plugin-descriptor setup. The service
is registered with the Bukkit `ServicesManager` at `ServicePriority.Normal` for the duration of the
Iris plugin's enabled lifetime.
```java
package com.example.integration;
import art.arcane.iris.api.terrain.IrisTerrainService;
import org.bukkit.Bukkit;
import org.bukkit.plugin.RegisteredServiceProvider;
public final class TerrainAccess {
private TerrainAccess() {
}
public static IrisTerrainService service() {
RegisteredServiceProvider<IrisTerrainService> provider =
Bukkit.getServicesManager().getRegistration(IrisTerrainService.class);
return provider == null ? null : provider.getProvider();
}
}
```
There is no `Iris` class to import, no static accessor and no reflection. If the registration is
missing, Iris is absent or has not enabled yet; that is a `null` and not an exception.
---
## The read surface
```java
public interface IrisTerrainService {
boolean isIrisWorld(World world);
Optional<IrisWorldInfo> worldInfo(World world);
OptionalInt surfaceHeight(World world, int blockX, int blockZ);
IrisSurfaceKind surfaceKind(World world, int blockX, int blockZ);
Optional<String> surfaceBiomeKey(World world, int blockX, int blockZ);
Optional<String> surfaceBiomeName(World world, int blockX, int blockZ);
Optional<String> biomeKey(World world, int blockX, int blockY, int blockZ);
Optional<String> regionKey(World world, int blockX, int blockZ);
Optional<String> regionName(World world, int blockX, int blockZ);
int maxSampleColumns();
int maxSampleChunks();
boolean sampleColumns(World world, IrisColumnQuery query, IrisColumnSink sink);
}
```
All coordinates are **absolute block coordinates in world space**, including `blockY` and including
the value returned by `surfaceHeight`. There is no engine-space offset for a caller to apply.
`*Key` returns a pack load key — `desert/hot-dunes`, `overworld` — which is stable, lowercase and
what you store. `*Name` returns the author's display string — `Hot Desert Dunes` — which is what you
show and which can change when the pack author edits it. Both are `Optional` and both are empty when
the underlying value is absent or the empty string.
---
## Cost and blocking
This is the whole story. Read it before you write a loop.
Iris's generator is a stack of procedural noise streams. Every read below evaluates that stack for
one column and memoises the result in a shared per-chunk noise cache. A **cold** column runs the
pack's noise; a **warm** column is an array index. Nothing on this page reads chunk storage, reads a
block, loads a region file, takes a lock, waits on a future, or asks the server to generate
anything.
| Call | Cost when cold | Cost when warm | Forces generation | Can block | When the data is not there |
|---|---|---|---|---|---|
| `isIrisWorld` | one `World#getGenerator()` and an `instanceof` | same | No | No | `false` |
| `worldInfo` | field reads off the live engine and dimension | same | No | No | `Optional.empty()` |
| `surfaceHeight` | one height sample, which pulls the region and base-biome streams for that column | array read | No | No | `OptionalInt.empty()` |
| `surfaceKind` | one height sample, plus one surface-biome sample **only** for columns above fluid level | array read | No | No | `IrisSurfaceKind.UNKNOWN` |
| `surfaceBiomeKey` / `surfaceBiomeName` | one surface-biome sample, which pulls height, base biome and region | array read | No | No | `Optional.empty()` |
| `biomeKey` at or near the surface | as `surfaceBiomeKey`, plus one height sample to decide surface vs cave | array read | No | No | `Optional.empty()` |
| `biomeKey` well below the surface | the above, plus the cave-biome stream and the dimension's carving resolution | array reads | No | No | `Optional.empty()` |
| `regionKey` / `regionName` | one region sample — the cheapest of the biome family | array read | No | No | `Optional.empty()` |
| `maxSampleColumns` / `maxSampleChunks` | reads two settings fields | same | No | No | a positive number, always |
| `sampleColumns` | one of the above per column, in chunk-local order | array reads | No | No | `false`, sink untouched |
Two consequences that matter more than the per-call cost:
**Calling in a tight main-thread loop is survivable but wasteful.** Nothing will deadlock and
nothing will stall on I/O. What you will do is evict the generator's own working set: the noise
cache is shared with live chunk generation, and a scan across unrelated coordinates pushes out the
columns the generator was about to reuse. The visible symptom is chunk generation slowing down, not
your loop slowing down. Use `sampleColumns` for anything wider than a handful of columns — it walks
chunk by chunk so each cached chunk is filled and finished with before the next one starts.
**These values are the generator's opinion, not the world's.** `surfaceHeight` is the height of the
generated terrain column. It does not include objects, decorations, structures, trees, snow, or
anything a player has placed or broken since. In an already-generated world the block at that Y may
be different, and in a world that has never generated there you still get an answer, because the
answer comes from noise and not from storage. If you need the real block, use Bukkit's
`World#getHighestBlockYAt` and accept its chunk-loading cost. If you need to know where the pack
*intends* the ground to be — which is the useful question for a pregeneration planner, a map
renderer or a spawn picker — use this.
### Surface height, precisely
`surfaceHeight` returns the absolute Y of the **topmost generated terrain block**. A player stands
at `surfaceHeight + 1`. Fluid is ignored: under an ocean you get the sea floor, not the water
surface. Compare against `IrisWorldInfo.fluidHeight()` to tell the difference, or use
`surfaceKind`, which does exactly that comparison for you.
---
## Threading
**Every read on this interface may be called from any thread, including an async task.** That is an
unusual claim in a Folia-aware suite and it is made deliberately, so here is the justification:
- The only Bukkit call Iris makes on your behalf is `World#getGenerator()`, an accessor on the world
object itself. No chunk is touched, no block state is read, no entity is looked at, no world list
is walked.
- Everything after that is engine-internal noise evaluation over concurrent caches. There is no
region-owned state involved, so there is no region thread with a claim on it.
- No method here takes a lock you can contend on, calls `join`, or schedules onto another thread.
There is nothing to gain from hopping to a region thread first, and on Folia there is no region
thread that would be the *correct* one for a coordinate scan spanning many regions anyway. Run wide
scans on your own async executor.
The one rule: **`IrisColumnSink.accept` runs on the thread that called `sampleColumns`, inline,
once per column.** If you called from an async thread, your sink is on that async thread and must
not touch Bukkit state. If you called from a region thread, your sink is holding that region thread
for the entire walk. Collect into a local structure inside the sink and do the Bukkit work
afterwards.
---
## Column sampling
`sampleColumns` is the bulk read. It walks a rectangle at a stride, chunk by chunk, and pushes each
column into your sink.
```java
public record IrisColumnQuery(
int minBlockX,
int minBlockZ,
int maxBlockX,
int maxBlockZ,
int strideBlocks,
EnumSet<IrisColumnField> fields) {
public static IrisColumnQuery rect(
int minBlockX,
int minBlockZ,
int maxBlockX,
int maxBlockZ,
int strideBlocks,
EnumSet<IrisColumnField> fields);
public long columnCount();
public long chunkCount();
public EnumSet<IrisColumnField> fields();
}
```
The bounds are **inclusive on both ends**. The sampled lattice is anchored at
`(minBlockX, minBlockZ)` and steps by `strideBlocks`; a stride of `1` visits every column.
The constructor rejects, with `IllegalArgumentException`:
- an empty `fields` set,
- `maxBlockX < minBlockX` or `maxBlockZ < minBlockZ`,
- `strideBlocks < 1`.
`fields` is defensively copied on the way in and on every call to `fields()`, so a set you mutate
after construction does not change the query, and a set you get back and mutate does not either.
`fields()` allocates a fresh `EnumSet` each call — hoist it out of loops.
`columnCount()` and `chunkCount()` saturate at `Long.MAX_VALUE` instead of overflowing, so a query
over the whole coordinate space reports an absurd number rather than a negative one.
### The hard limits
```java
int maxSampleColumns();
int maxSampleChunks();
```
Both are derived from the generator's noise cache size, so a large-cache server permits larger
queries and a small-cache server permits smaller ones. The rule is fixed:
```
maxSampleChunks = max(64, noiseCacheSize / 4)
maxSampleColumns = maxSampleChunks * 256
```
With the default `noiseCacheSize` of 1024 that is **256 chunks and 65 536 columns**. The divisor of
four is the point of the whole mechanism: one API query may never consume more than a quarter of the
cache the live generator is using.
**A query that exceeds either limit returns `false` and never calls your sink — not once.** There is
no partial answer, no truncation, no exception, and no log line. If you get `false` before any
column arrives, check the counts.
The two limits are checked independently, and this is where callers get caught:
```java
IrisColumnQuery wide = IrisColumnQuery.rect(
0, 0, 6399, 6399, 64, EnumSet.of(IrisColumnField.SURFACE_KIND));
```
That query reports `columnCount() == 10_000`, well under the 65 536 column limit, and
`chunkCount() == 160_000`, far over the 256 chunk limit. It is refused.
**`chunkCount()` counts the chunk span of the rectangle, not the chunks you actually sample.**
Striding does not reduce it. A coarse sweep across a large area is refused on chunks even though it
touches very few columns. Split it into tiles, or accept a smaller rectangle:
```java
long maxColumns = terrain.maxSampleColumns();
long maxChunks = terrain.maxSampleChunks();
if (query.columnCount() > maxColumns || query.chunkCount() > maxChunks) {
return;
}
```
Ask the service every time. Both values change when an operator edits the setting and reloads.
### The sink
```java
@FunctionalInterface
public interface IrisColumnSink {
void accept(int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey);
}
```
Every column produces exactly one `accept`. What arrives depends on the `fields` you asked for, and
the placeholders for fields you did **not** ask for are not distinguishable from real data:
| Field requested | Parameter | If you asked for it | If you did not |
|---|---|---|---|
| `SURFACE_HEIGHT` | `surfaceHeight` | absolute world Y of the topmost terrain block | `-1` |
| `SURFACE_KIND` | `kind` | `LAND`, `SHORE`, `OCEAN` or `VOID` | `IrisSurfaceKind.UNKNOWN` |
| `BIOME_KEY` | `biomeKey` | the biome load key | `null` |
`-1` is a legal absolute Y in any world with a negative minimum height, so **never treat `-1` as
"absent"**. Branch on your own field set, which you already have. `biomeKey` may also be `null` when
you *did* ask for it, if the column resolves to no biome; test for `null` regardless.
Requesting fewer fields genuinely costs less. `SURFACE_KIND` alone does not evaluate the biome
stream for a column that is at or below fluid level, because the classification is already decided.
Ask for `BIOME_KEY` and every column pays for the biome stream.
### Visit order
Columns arrive grouped by chunk. The walk iterates chunks with Z as the outer loop and X as the
inner loop, and within each chunk iterates its lattice points the same way, Z outer and X inner.
Order is deterministic for a given query, but it is **not** a row-major sweep of the rectangle: you
receive all of one chunk's columns before any of the next chunk's. If your consumer needs raster
order, sort afterwards or index into an array by `(blockX, blockZ)`.
### The return value
`sampleColumns` returns `true` if and only if every column in the query was delivered. It returns
`false` when:
- `world`, `query` or `sink` is `null`, or the world has no live Iris engine — sink untouched;
- a limit was exceeded — sink untouched;
- **your sink threw** — the walk stops at that column;
- **the engine closed underneath the walk** — the walk stops at that column.
In the last two cases the columns already delivered were delivered. `false` does not mean "nothing
happened"; it means "do not trust this result set as complete". Treat a `false` as a signal to
discard the partial data, not as a signal that there is none.
---
## Worked example: finding the flattest buildable spot
A plugin that places a settlement wants the flattest patch of land inside a radius, and wants none
of that work on a region thread. It samples on an async task, then hands the answer to the player's
entity scheduler, which is the correct thread to touch a player on Folia and on Paper alike.
```java
package com.example.settlement;
import art.arcane.iris.api.terrain.IrisColumnField;
import art.arcane.iris.api.terrain.IrisColumnQuery;
import art.arcane.iris.api.terrain.IrisColumnSink;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.api.terrain.IrisTerrainService;
import art.arcane.iris.api.terrain.IrisWorldInfo;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.RegisteredServiceProvider;
import java.util.EnumSet;
import java.util.Optional;
import java.util.concurrent.Executor;
public final class SettlementSiteFinder {
private static final int RADIUS_BLOCKS = 512;
private static final int STRIDE_BLOCKS = 8;
private final Plugin plugin;
private final Executor background;
public SettlementSiteFinder(Plugin plugin, Executor background) {
this.plugin = plugin;
this.background = background;
}
public void findFor(Player player) {
World world = player.getWorld();
Location origin = player.getLocation();
int centreX = origin.getBlockX();
int centreZ = origin.getBlockZ();
background.execute(() -> {
String result = search(world, centreX, centreZ);
player.getScheduler().run(plugin, task -> player.sendMessage(result), null);
});
}
private String search(World world, int centreX, int centreZ) {
IrisTerrainService terrain = service();
if (terrain == null || !terrain.isIrisWorld(world)) {
return "That world is not generated by Iris.";
}
Optional<IrisWorldInfo> info = terrain.worldInfo(world);
if (info.isEmpty()) {
return "The Iris engine for that world is not available right now.";
}
IrisColumnQuery query = IrisColumnQuery.rect(
centreX - RADIUS_BLOCKS,
centreZ - RADIUS_BLOCKS,
centreX + RADIUS_BLOCKS,
centreZ + RADIUS_BLOCKS,
STRIDE_BLOCKS,
EnumSet.of(IrisColumnField.SURFACE_HEIGHT, IrisColumnField.SURFACE_KIND));
if (query.columnCount() > terrain.maxSampleColumns()
|| query.chunkCount() > terrain.maxSampleChunks()) {
return "That search area is larger than this server allows.";
}
int fluidHeight = info.get().fluidHeight();
Best best = new Best();
IrisColumnSink sink = (int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey) -> {
if (kind != IrisSurfaceKind.LAND || surfaceHeight <= fluidHeight) {
return;
}
long score = (long) Math.abs(surfaceHeight - fluidHeight) * 1024L
+ Math.abs(blockX - centreX) + Math.abs(blockZ - centreZ);
if (score < best.score) {
best.score = score;
best.x = blockX;
best.y = surfaceHeight;
best.z = blockZ;
}
};
if (!terrain.sampleColumns(world, query, sink)) {
return "The terrain scan did not complete. Try again.";
}
if (best.score == Long.MAX_VALUE) {
return "No dry land within " + RADIUS_BLOCKS + " blocks.";
}
return "Best site: " + best.x + ", " + (best.y + 1) + ", " + best.z;
}
private IrisTerrainService service() {
RegisteredServiceProvider<IrisTerrainService> provider =
plugin.getServer().getServicesManager().getRegistration(IrisTerrainService.class);
return provider == null ? null : provider.getProvider();
}
private static final class Best {
private long score = Long.MAX_VALUE;
private int x;
private int y;
private int z;
}
}
```
`Best` needs no synchronisation: the sink runs inline on the thread that called `sampleColumns`, so
every `accept` for this walk is on the background thread that started it, and no other thread reads
the holder until the walk has returned. The `+ 1` on the reported Y is the standing height, since
`surfaceHeight` is the topmost solid block. `player.getScheduler()` is Paper's entity scheduler and
is the correct hop on both Paper and Folia; on Folia it resumes on whichever region owns the player
at that moment, which may not be the region they were in when the scan started.
---
## The minimum: one coordinate
Most integrations want one biome name at one place. That is three lines and needs none of the above.
```java
IrisTerrainService terrain = service();
String biome = terrain == null
? "unknown"
: terrain.surfaceBiomeName(player.getWorld(), player.getLocation().getBlockX(),
player.getLocation().getBlockZ()).orElse("unknown");
```
`surfaceBiomeName` on a non-Iris world, a null world, a closing engine or a disabled Iris returns
`Optional.empty()`. You do not need to call `isIrisWorld` first unless you want to distinguish
"not an Iris world" from "Iris has nothing to say".
---
## What `IrisWorldInfo` tells you
```java
public record IrisWorldInfo(
String dimensionKey,
String worldIdentity,
long seed,
int minHeight,
int maxHeight,
int fluidHeight,
boolean studio) {
public int height();
}
```
| Component | What it is |
|---|---|
| `dimensionKey` | Pack load key of the dimension, for example `overworld` |
| `worldIdentity` | The world's namespaced key rendered as a string, for example `minecraft:overworld` |
| `seed` | The raw seed the engine was built with |
| `minHeight` | Absolute Y of the world floor, for example `-64` |
| `maxHeight` | Absolute Y of the world ceiling, exclusive, for example `320` |
| `fluidHeight` | Absolute Y of the pack's sea level |
| `studio` | `true` only for a transient studio world |
| `height()` | `maxHeight - minHeight` |
`minHeight`, `maxHeight` and `fluidHeight` are all absolute world Y, directly comparable with
`surfaceHeight` and with `blockY`. The record's own constructor rejects a null `dimensionKey` or
`worldIdentity` with `NullPointerException` and a non-positive height range with
`IllegalArgumentException`, so an instance you receive is always internally consistent.
`worldIdentity` is the string form of the world's `NamespacedKey`, and it is the key Iris itself
persists per-world state under. It is the right key for you to persist too, because it is namespaced
and unambiguous where a bare name is not. It is **not** independent of the world's name: outside the
three vanilla dimensions the server derives the key from the world folder, so renaming that folder
changes `worldIdentity` exactly as it changes `World#getName()`.
`studio` is `true` for a world Iris created for pack authoring — those exist for seconds and are
deleted, so a persistence layer should skip them.
`seed` is the generator seed. Treat it as privileged: it is enough to reproduce the entire world
offline, including every ore vein and structure. Iris deliberately does not expose it through
PlaceholderAPI for that reason. Do not put it anywhere a player can read.
---
## Failure policy
Iris assumes the caller will pass nulls, hand it a world it does not own, keep a stale service
reference, and throw from a sink.
| Situation | What Iris does |
|---|---|
| `world` is `null` | Every query answers absent; `sampleColumns` returns `false` |
| The world has no Iris generator | Same |
| Iris is disabled, or disabled between your two calls | Same. Nothing throws |
| The generator is closing, or the engine is closed | `isIrisWorld` still returns **`true`**; every other query answers absent |
| A query throws inside the engine | Counted, logged with the stack trace, answered as absent |
| `query` or `sink` is `null` | `sampleColumns` returns `false`, sink never called |
| The query exceeds `maxSampleColumns` or `maxSampleChunks` | `sampleColumns` returns `false`, sink never called, nothing logged |
| Your sink throws | Walk aborts at that column, fault counted and logged, `sampleColumns` returns `false`. Columns already delivered stay delivered |
| The engine closes mid-walk | Walk stops at that column, `sampleColumns` returns `false` |
Two deliberate asymmetries worth internalising:
**`isIrisWorld` does not check liveness.** It answers "was this world created by Iris", not "can
Iris answer questions about it right now". During world unload and during plugin shutdown you will
see `isIrisWorld(world) == true` alongside `worldInfo(world).isEmpty()`. That is correct behaviour,
not a race you can win. Code that branches on `isIrisWorld` and then dereferences an
`Optional#get()` will throw eventually; use `orElse` or check the `Optional`.
**Iris never quarantines a caller.** There is no fault limit and no disable-after-N. A sink that
throws on every column will be logged and refused on every call, forever, and will never be muted or
blacklisted. The internal fault counters exist only to throttle the log line to at most one report
per minute per category — the count in that line tells you how many faults have occurred in total,
so a "3 faults" line followed by a "9000 faults" line means you have a loop, not two incidents.
Nothing in this API ever throws a checked exception, and nothing throws an unchecked one except the
argument validation on `IrisColumnQuery` and `IrisWorldInfo` constructors described above.
---
## Configuration
`plugins/Iris/settings.json`:
| Key | Default | Effect on this API |
|---|---|---|
| `performance.noiseCacheSize` | `1024` | The chunk capacity of the shared noise cache. `maxSampleChunks` is `max(64, this / 4)` and `maxSampleColumns` is `maxSampleChunks * 256`. Raising it raises both limits and the memory the generator holds |
There is no on/off switch for the terrain API and no per-world gate. It answers for every world with
a live Iris engine, and answers absent for everything else.
---
## Enum reference
### `IrisSurfaceKind`
Returned by `surfaceKind` and delivered to `IrisColumnSink`.
| Constant | Meaning | Test Iris applies |
|---|---|---|
| `LAND` | Dry ground | Surface above sea level, and the biome is not a shore biome |
| `SHORE` | Beach or bank | Surface above sea level, and the pack classifies the biome as shore |
| `OCEAN` | Under water | Surface at or below `IrisWorldInfo.fluidHeight()` |
| `VOID` | Nothing generated | Surface at or below `IrisWorldInfo.minHeight()` |
| `UNKNOWN` | No answer | Not an Iris world, the engine is unavailable, a query faulted, or `SURFACE_KIND` was not requested |
**`VOID` is tested first and wins.** A column at or below `minHeight()` reports `VOID` whatever the
sea level is; only a column above the floor is then tested against the fluid level, and only a column
above the fluid level is then tested for a shore biome. The four are mutually exclusive.
`OCEAN` is inclusive at the boundary: a column whose topmost terrain block sits exactly at sea level
reports `OCEAN` even though no water block is generated above it. If that one-block distinction
matters, compare `surfaceHeight` against `fluidHeight` yourself.
`UNKNOWN` is overloaded on purpose — it is the single "no data" value, so a caller never has to
handle both a sentinel and an exception. Distinguish the causes with `isIrisWorld` and `worldInfo`
if you need to.
### `IrisColumnField`
Selects what `sampleColumns` computes and passes to the sink. At least one is required.
| Constant | Fills | Extra work |
|---|---|---|
| `SURFACE_HEIGHT` | the `surfaceHeight` parameter | one height sample per column |
| `SURFACE_KIND` | the `kind` parameter | one height sample, plus a biome sample only for columns above sea level |
| `BIOME_KEY` | the `biomeKey` parameter | one biome sample per column, unconditionally |
`SURFACE_HEIGHT` and `SURFACE_KIND` share their height sample — asking for both costs barely more
than asking for either.
+517
View File
@@ -0,0 +1,517 @@
# Iris tree feller API
`art.arcane.iris.api.tree` lets another plugin **drive** the Iris tree feller and **charge** for it.
The feller removes a whole Iris-generated tree, block by block, when a sneaking survival player
breaks one of its logs with an axe. This API lets you turn it on for a player who would not
otherwise be allowed it, override the durability rules, and take something from that player for each
log removed — with a reservation you can get back if the log turns out not to be removable.
There are two things you can do, and they are independent:
| You want to… | Use |
|---|---|
| start a felling run that Iris would not have started, or price it | `IrisTreeFellerService#tryFell` with `TreeFellerOptions.integrationOverride(...)` |
| avoid double-handling the block breaks Iris generates while felling | `IrisTreeFellerService#isManagedBreak` |
| ask whether a block belongs to an Iris tree at all | `IrisTreeFellerService#isTreeBlock` |
**The tree feller is off by default.** `treeFeller.enabled` in Iris's settings is `false` out of the
box, and the standalone path additionally requires the `iris.treefeller` permission. An
`INTEGRATION_OVERRIDE` request bypasses **both** — that is what the mode is for, and it means your
plugin is now the thing that decides who may fell trees.
---
## Depending on Iris and acquiring the service
See [README.md](README.md#depending-on-iris) for the build and plugin-descriptor setup. The service
is registered with the Bukkit `ServicesManager` at `ServicePriority.Normal`.
```java
package com.example.woodcutting;
import art.arcane.iris.api.tree.IrisTreeFellerService;
import org.bukkit.Bukkit;
import org.bukkit.plugin.RegisteredServiceProvider;
public final class FellerAccess {
private FellerAccess() {
}
public static IrisTreeFellerService service() {
RegisteredServiceProvider<IrisTreeFellerService> provider =
Bukkit.getServicesManager().getRegistration(IrisTreeFellerService.class);
return provider == null ? null : provider.getProvider();
}
}
```
```java
public interface IrisTreeFellerService {
boolean tryFell(BlockBreakEvent event, TreeFellerOptions options);
boolean isManagedBreak(BlockBreakEvent event);
boolean isTreeBlock(Block block);
}
```
---
## The lifecycle
```
your BlockBreakEvent handler
|
v
tryFell(event, options) register a felling request against this break.
| Returns true when YOUR request is pending. Nothing has
| happened yet and no hook has fired.
|
| (Iris re-checks everything at EventPriority.MONITOR)
v
onActivationAccepted() the run is real. Fires exactly once, if at all.
|
| (per LOG block, in the order the tree comes apart)
v
reserveLogCost() -> false you refuse. The run ends. Nothing to give back.
|
| true
v
+--> commitLogCost() the log is gone. The charge is yours. FINAL.
+--> refundLogCost() the log could not be removed. Give it back.
```
Rules Iris guarantees:
- `onActivationAccepted` fires **at most once per run**, and only after Iris has re-validated the
break at `MONITOR`: the event was not cancelled, the block still resolves to the same Iris tree,
and no other run already claims that tree.
- `reserveLogCost` is called **once per log block**, not once per run. A twelve-log tree calls it up
to twelve times. **Leaves never reserve** — they are removed without consulting you.
- `reserveLogCost` is called **before** Iris charges the axe's durability, so a refusal costs the
player nothing at all.
- Exactly **one** of `commitLogCost` or `refundLogCost` follows a `reserveLogCost` that returned
`true`, with the one exception described under [Failure policy](#failure-policy).
- **`commitLogCost` is final.** There is no reversal after it, and Iris will not call
`refundLogCost` for a block it has already committed.
- A `reserveLogCost` that returns `false` ends the whole run immediately. It does not skip that log
and continue.
- A tree can only be felled by one run at a time, server-wide. A second player breaking the same
tree while a run is in flight has their break cancelled with drops suppressed, and no hook of
yours is called for it.
There is **no terminal callback.** `TreeFellerRunHooks` has no "the run finished" method. If your
accounting needs to know when a run ended, count `commitLogCost` and `refundLogCost` calls against
the `onActivationAccepted` that opened the run, and treat a run with no activity as over.
---
## Threading
Three different threads are involved and the distinction matters, because two of them are region
threads on Folia and one is an entity scheduler.
| Call | Thread |
|---|---|
| `tryFell` | You call it. It must be the thread delivering the `BlockBreakEvent` — the region thread that owns the broken block |
| `isManagedBreak` | Any thread. It is a set lookup and touches nothing else |
| `isTreeBlock` | The region thread that owns the block. It reads block state **and** can block on disk — see below |
| `onActivationAccepted` | The region thread that owns the broken block, inline in the `MONITOR` dispatch |
| `reserveLogCost` | The **feller's entity scheduler thread** |
| `commitLogCost` | The feller's entity scheduler thread |
| `refundLogCost` | The feller's entity scheduler thread |
The three cost hooks run on the player's entity scheduler, which is the thread that owns that player
on Folia. Reading and mutating the feller's inventory, experience and effects is legal there. The
player's *world* is not yours on that thread — do not read or write blocks from a cost hook.
`onActivationAccepted` runs on the block's region thread, inline inside the `BlockBreakEvent`
dispatch at `MONITOR`. Blocks and the player are both legal to touch there, but you are inside event
dispatch: return promptly.
**Do not block, in any of the four.** No I/O, no `CompletableFuture#join`, no locks held across the
call. Iris does not interrupt a hook that hangs and does not time it out; the contract is the only
protection. If a cost decision needs remote data, cache it — prime it on `PlayerJoinEvent`.
### `isTreeBlock` is the expensive one
`isTreeBlock` reads Iris's mantle — the generator's persistent per-region metadata store — to find
out whether the block was placed by an Iris tree. If the mantle region covering that block is not
resident in memory, **this call loads it from disk, synchronously, on your thread.** It also reads
the block's type and block data, so the chunk must be loaded and you must be on the region thread
that owns it.
Concretely: on a first touch in a cold area it does a filesystem stat, and possibly a full region
load and decompress, before it answers. On a warm area it is a couple of map lookups.
Do not call it per block in a loop, per tick, or on a large area. Nothing else in this API touches
the mantle; if you are calling `isTreeBlock` speculatively rather than about a block a player just
interacted with, you are using it wrong.
---
## Worked example: charging stamina per log
A plugin with its own stamina pool. It lets players fell trees regardless of Iris's permission and
enabled switch, charges 4 stamina per log, gives it back when a log turns out not to be removable,
and preserves the axe 50% of the time.
### The hooks
```java
package com.example.woodcutting;
import art.arcane.iris.api.tree.TreeFellerRunHooks;
import java.util.UUID;
public final class StaminaFellHooks implements TreeFellerRunHooks {
private static final int COST_PER_LOG = 4;
private final StaminaPool pool;
private final UUID fellerId;
public StaminaFellHooks(StaminaPool pool, UUID fellerId) {
this.pool = pool;
this.fellerId = fellerId;
}
@Override
public void onActivationAccepted() {
pool.beginRun(fellerId);
}
@Override
public boolean reserveLogCost() {
return pool.withdraw(fellerId, COST_PER_LOG);
}
@Override
public void commitLogCost() {
pool.recordSpend(fellerId, COST_PER_LOG);
}
@Override
public void refundLogCost() {
pool.deposit(fellerId, COST_PER_LOG);
}
}
```
`TreeFellerRunHooks` declares all four methods and none of them has a default, so an implementation
must provide all four even when three are empty. `TreeFellerRunHooks.NONE` is the shared no-op
implementation whose `reserveLogCost` returns `true`; use it when you want the override behaviour
without a cost.
The hooks instance is **per run**, not per plugin. Build a new one for each `tryFell` call and put
the feller's identity in it — Iris hands the same instance back for every callback of that run and
never inspects it, so it is the natural place to carry run state.
### The listener
```java
package com.example.woodcutting;
import art.arcane.iris.api.tree.IrisTreeFellerService;
import art.arcane.iris.api.tree.TreeFellerOptions;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
public final class WoodcuttingListener implements Listener {
private static final int PRESERVE_PERCENT = 50;
private final StaminaPool pool;
public WoodcuttingListener(StaminaPool pool) {
this.pool = pool;
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onBreak(BlockBreakEvent event) {
IrisTreeFellerService feller = FellerAccess.service();
if (feller == null || feller.isManagedBreak(event)) {
return;
}
Player player = event.getPlayer();
if (!pool.hasWoodcutting(player.getUniqueId())) {
return;
}
TreeFellerOptions options = TreeFellerOptions.integrationOverride(
PRESERVE_PERCENT, new StaminaFellHooks(pool, player.getUniqueId()));
feller.tryFell(event, options);
}
}
```
The `isManagedBreak` guard is not optional. While a run is in progress Iris fires a
`BlockBreakEvent` for **every block it removes**, so that protection plugins and loggers see the
removals. Without the guard your listener would call `tryFell` on Iris's own break events and your
stamina check would run once per block of the tree.
`EventPriority.HIGH` is a deliberate choice, and it is load-bearing. It is after the priorities a
protection plugin normally uses to cancel, and — the part that matters — strictly before `HIGHEST`,
which is where Iris asks for its own standalone run. A break is claimed by the first `tryFell` that
succeeds against it, so a handler at `HIGHEST` or `MONITOR` can find that Iris has already taken it.
See [What `tryFell` actually promises](#what-tryfell-actually-promises).
Registration is ordinary:
```java
@Override
public void onEnable() {
getServer().getPluginManager().registerEvents(new WoodcuttingListener(pool), this);
}
```
---
## The minimum: turn the feller on, charge nothing
If you only want players in your woodcutting class to fell trees, with Iris's own durability
behaviour and no cost:
```java
IrisTreeFellerService feller = FellerAccess.service();
if (feller != null && !feller.isManagedBreak(event) && classes.isWoodcutter(event.getPlayer())) {
feller.tryFell(event, TreeFellerOptions.integrationOverride(0, TreeFellerRunHooks.NONE));
}
```
Put that in a `BlockBreakEvent` handler at a priority earlier than `HIGHEST` — Iris asks for its own
standalone run at `HIGHEST`, and the first request to succeed claims the break.
`TreeFellerRunHooks.NONE` never refuses and never charges. A `durabilityPreservationChance` of `0`
means every log costs one point of axe durability, which is vanilla-equivalent.
`TreeFellerOptions.standalone()` exists for completeness — it is the request Iris makes for itself —
and there is almost never a reason for a third party to pass it. It respects the enabled switch and
the permission, so it can only ever do what Iris would already have done.
---
## What `tryFell` actually promises
```java
boolean tryFell(BlockBreakEvent event, TreeFellerOptions options);
```
`true` means **your felling request is pending against this break**. It does not mean a tree will
fall — Iris re-validates everything at `MONITOR` and can still drop the request there.
**A break is claimed by the first `tryFell` that succeeds against it.** The moment a request is
accepted, Iris marks that `BlockBreakEvent` as managed, and every later `tryFell` for the same event
returns `false` immediately, without looking at your `access` at all. There is no displacement and no
last-writer-wins: whoever asks first, in event-priority order, owns the break.
| State when you call | Your `access` | Result |
|---|---|---|
| Nothing pending | either | Your request becomes pending. Returns `true` |
| A request already accepted for this break | either | The existing one stays. Returns `false` |
That has one consequence you must design around. **Iris makes its own `STANDALONE` request from a
listener at `EventPriority.HIGHEST`.** If your handler runs at `HIGHEST` and happens to be registered
after Iris's, or at `MONITOR`, Iris has already claimed the break and your override is refused. Call
`tryFell` from a handler at a priority strictly earlier than `HIGHEST``LOWEST`, `LOW`, `NORMAL` or
`HIGH` — and your `INTEGRATION_OVERRIDE` is the one that lands. `HIGH` is the usual choice.
Two plugins that both want to override the same break resolve the same way: the earlier priority
wins, and the later one gets `false` and knows it lost. Nothing is silently discarded.
`false` means no request of yours is pending. Iris returns `false` when:
- the service is disabled, or `event` or `options` is `null`;
- the event is already cancelled;
- the event is one Iris is already managing — either a break another request has already claimed, or
one of the per-block probe events Iris fires during a run. `isManagedBreak` answers both;
- `canUse` failed — for `STANDALONE` that means `treeFeller.enabled` is `false` or the player lacks
`iris.treefeller`; an `INTEGRATION_OVERRIDE` never fails this check;
- the break is not a fellable candidate.
`true` is still not a run. The `MONITOR` re-validation drops the request if the event was cancelled
after you asked, if the block no longer resolves to the same Iris tree, or if another run already
claims that tree — and in none of those cases does a hook fire. Open your run state in
`onActivationAccepted`, not at `tryFell`.
### What makes a break a candidate
An `INTEGRATION_OVERRIDE` bypasses the enabled switch and the permission. It does **not** bypass any
of these, and there is no option to:
- the player is in `GameMode.SURVIVAL`;
- the player is sneaking;
- the broken block is tagged `Tag.LOGS`;
- the item in the player's main hand is an axe;
- the block carries Iris tree provenance in the mantle — it was placed by an Iris tree, has not been
replaced since, and is not part of a structure.
A tree the player planted with a vanilla sapling is not an Iris tree and will never fell. Neither is
a log a player placed by hand: Iris clears the provenance record for a block as soon as it is broken
or built over.
---
## How a run comes apart
Once activated, Iris discovers the tree by walking the mantle provenance markers outward from the
broken block in all 26 directions, breadth-first. Members are then removed in that discovery order —
the block the player broke first, then outward — with ties broken by Y, then X, then Z.
Discovery is bounded. If any bound is hit the discovery is **incomplete**, and Iris falls back to
removing only the block the player actually broke:
| Bound | Value |
|---|---|
| Members collected | 131 072 |
| Positions visited | 1 000 000 |
| Distance from the broken block on any axis | 256 blocks |
Removal is paced: Iris removes a batch of blocks, then yields for a tick before the next batch, so a
large tree takes several ticks and does not stall a region. Batch size scales with the tree.
A run ends immediately, with no further hooks, when the player:
- stops sneaking,
- changes their held hotbar slot,
- swaps their hands,
- goes offline, leaves survival mode, or changes world,
- breaks their axe (the run ends after the log that broke it is committed),
- or replaces the axe in that slot with a different item.
Each removed block fires its own `BlockBreakEvent`, marked so that `isManagedBreak` returns `true`
for it during dispatch. Other plugins can cancel that event to protect a block. A cancelled probe on
a **log** refunds that log's reservation and ends the run; a cancelled probe on a **leaf** has no
reservation to give back and the run simply carries on to the next member. Drops for each block are
computed with the axe **as it was before that block's durability charge**, so enchantments like Silk
Touch and Fortune apply normally.
The original break event is cancelled by Iris with drops and experience suppressed, because Iris
delivers them itself per block instead.
---
## What the options carry
```java
public record TreeFellerOptions(
TreeFellerAccess access,
int durabilityPreservationChance,
TreeFellerRunHooks runHooks) {
public static TreeFellerOptions standalone();
public static TreeFellerOptions integrationOverride(
int durabilityPreservationChance,
TreeFellerRunHooks runHooks);
}
```
The canonical constructor throws `NullPointerException` for a null `access` or `runHooks`, and
`IllegalArgumentException` for a `durabilityPreservationChance` outside `0 .. 100`. Both factory
methods go through it, so `TreeFellerOptions.integrationOverride(101, hooks)` throws at the call
site rather than clamping silently.
`durabilityPreservationChance` is a percentage: the chance that removing one log costs the axe no
durability at all. `0` charges every log; `100` never charges. It is rolled independently per log.
An unbreakable axe is never charged whatever the value.
**The value is only honoured for `INTEGRATION_OVERRIDE`.** A `STANDALONE` request ignores whatever
you passed and uses `treeFeller.durabilityPreservationChance` from Iris's settings —
`TreeFellerOptions.standalone()` hard-codes `0` in the record for exactly that reason.
```java
public interface TreeFellerRunHooks {
TreeFellerRunHooks NONE;
void onActivationAccepted();
boolean reserveLogCost();
void commitLogCost();
void refundLogCost();
}
```
Iris never calls anything else on your hooks object — not `equals`, not `hashCode`, not `toString`.
It holds the reference for the duration of the run and drops it when the run ends.
---
## Failure policy
Iris assumes a hooks implementation will throw, refuse late, or be handed a player who logs out
mid-run.
| Misbehaviour | What Iris does |
|---|---|
| `onActivationAccepted` throws | Logged with the stack trace. **The run continues** — activation is a notification, not a veto |
| `reserveLogCost` throws | Logged, treated as `false`. The run ends. Nothing is refunded, because nothing was reserved |
| `reserveLogCost` returns `false` | Not a fault. The run ends cleanly at that log |
| `commitLogCost` throws | Logged. The run ends. **The block is already gone and is not restored** |
| `refundLogCost` throws | Logged. The run ends |
| A hook blocks for a long time | Nothing. Iris does not time hooks out, does not warn, and cannot interrupt them |
| `tryFell` is passed a null event or options | Returns `false`. No hook is called |
| Two plugins request an override for one break | The one whose handler ran first wins. The other gets `false` and no hook of its own fires |
| Resolving the candidate throws | Logged. `tryFell` returns `false` |
| `isTreeBlock` throws | Logged. Returns `false` |
| Iris is disabled mid-run | Every active run is finished immediately. **No refund is issued for anything outstanding** |
**Iris does not quarantine a misbehaving integration.** There is no fault limit, no disable-after-N,
and no automatic unregistration. A hooks implementation that throws on every log will be logged on
every log, forever.
### The one place a refund can be missed
A refund is delivered by scheduling onto the feller's entity scheduler. If that scheduling fails —
the player has logged out, or been removed from the world, between the reservation and the failure
that triggers the refund — Iris finishes the run **without calling `refundLogCost`**. The same
applies to a plugin shutdown that ends runs in flight.
The exposure is at most one log's worth of cost per run, and only in the window between reserving a
log and resolving it, which is a single block removal. If a stricter guarantee matters to you, do
not settle the charge inside the hooks: accumulate reservations in your own per-run state keyed by
the feller, and reconcile on `PlayerQuitEvent` and on your own `onDisable`. The hooks tell you what
happened; they are not a transaction log you can rely on being complete across a disconnect.
---
## Configuration
`plugins/Iris/settings.json`:
| Key | Default | Meaning |
|---|---|---|
| `treeFeller.enabled` | `false` | Master switch for the **standalone** path only. When `false`, Iris never fells a tree on its own. An `INTEGRATION_OVERRIDE` request is unaffected |
| `treeFeller.durabilityPreservationChance` | `0` | Percentage chance a log costs no axe durability, for the standalone path only. Clamped to `0 .. 100` on read |
Permission, declared in the plugin descriptor:
| Node | Default | Meaning |
|---|---|---|
| `iris.treefeller` | `op` | Required for the standalone path. An `INTEGRATION_OVERRIDE` request does not check it |
---
## Enum reference
### `TreeFellerAccess`
| Constant | Enabled switch | `iris.treefeller` | `durabilityPreservationChance` source |
|---|---|---|---|
| `STANDALONE` | Required | Required | Iris settings; the value in your options is ignored |
| `INTEGRATION_OVERRIDE` | Bypassed | Bypassed | The value in your options |
Neither mode bypasses the candidate checks — survival, sneaking, an axe, a log, and Iris tree
provenance.
Write a `default` arm when switching over this enum; see
[README.md](README.md#switching-over-the-enums).
+459
View File
@@ -0,0 +1,459 @@
# 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](README.md#depending-on-iris) 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
```java
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
```java
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](terrain.md#what-irisworldinfo-tells-you). 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.
```java
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](README.md#switching-over-the-enums).
The map is a `ConcurrentHashMap` even though the handler is single-threaded, because
`dimensionKeyOf` is read from wherever your HUD renders.
---
## Pregeneration
```java
public enum IrisPregenPhase {
STARTED,
TICK,
PAUSED,
RESUMED,
SAVING,
COMPLETED,
CANCELLED
}
```
```java
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
```java
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
```java
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":
```java
@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](README.md#switching-over-the-enums).