18 KiB
91 - API - Terrain
art.arcane.iris.api.terrain answers what the Iris generator says about a coordinate: whether a world is Iris-generated, which biome and region the pack places, surface height, and whether that surface is land, shore, ocean, or void. It reads the generator, not the world: no chunk load, no forced generation, no placed-block read, and no knowledge of player edits. Reads are non-blocking noise evaluation over a shared per-chunk cache.
Build and service acquisition: 90 - API - Getting Started.md. Service: IrisTerrainService, registered at ServicePriority.Normal for Iris's enabled lifetime.
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();
}
}
Missing registration means Iris is absent or not enabled yet — null, not an exception. There is no static Iris accessor for this surface.
The read surface
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 surfaceHeight. There is no engine-space offset for the caller.
*Key returns a pack load key (desert/hot-dunes, overworld) — stable, lowercase, store this. *Name returns the author's display string (Hot Desert Dunes) — for display; it can change when the pack author edits it. Both are empty when the value is absent or the empty string.
Cost and blocking
Iris's generator is procedural noise. Each read evaluates the stack for one column and memoises in a shared per-chunk noise cache. Cold columns run pack noise; warm columns are array reads. Nothing here reads chunk storage, loads a region file, takes a contended lock, waits on a future, or asks the server to generate.
| Call | Cost when cold | Cost when warm | Forces generation | Can block | When data is absent |
|---|---|---|---|---|---|
isIrisWorld |
World#getGenerator() + instanceof |
same | No | No | false |
worldInfo |
field reads off live engine/dimension | same | No | No | Optional.empty() |
surfaceHeight |
one height sample (region + base-biome streams) | array read | No | No | OptionalInt.empty() |
surfaceKind |
height sample; surface-biome only when column is above fluid and not void floor | array read | No | No | IrisSurfaceKind.UNKNOWN |
surfaceBiomeKey / surfaceBiomeName |
surface-biome sample (height, base biome, region) | array read | No | No | Optional.empty() |
biomeKey at/near surface |
as surface biome + height to choose surface vs cave | array read | No | No | Optional.empty() |
biomeKey well below surface |
above + cave-biome stream and carving resolution | array reads | No | No | Optional.empty() |
regionKey / regionName |
region sample (cheapest biome-family call) | array read | No | No | Optional.empty() |
maxSampleColumns / maxSampleChunks |
settings fields | same | No | No | positive number always |
sampleColumns |
one of the above per column, chunk-local order | array reads | No | No | false, sink untouched |
Tight main-thread loops are non-blocking but wasteful. They can evict the generator's noise cache working set shared with live chunk generation — chunk gen slows, not your loop. Use sampleColumns for anything wider than a handful of columns.
Values are the generator's opinion, not the world's. surfaceHeight is the topmost generated terrain block Y. It excludes objects, decorations, structures, trees, snow, and player edits. For real blocks use Bukkit World#getHighestBlockYAt (chunk load cost). For pack intent (pregen planners, map renderers, spawn pickers) use this API.
Surface height, precisely
surfaceHeight returns absolute Y of the topmost generated terrain block. Standing height is surfaceHeight + 1. Fluid is ignored: under ocean you get the sea floor. Compare with IrisWorldInfo.fluidHeight() or use surfaceKind.
Threading
Every read may be called from any thread, including async.
- Only Bukkit call on your behalf:
World#getGenerator()on the world object. No chunk, block state, entity, or world-list walk. - After that: engine-internal noise over concurrent caches; no region-owned state.
- No method takes a lock you can contend on, calls
join, or schedules onto another thread.
Wide scans belong on your own async executor. On Folia there is no single correct region thread for a multi-region scan.
IrisColumnSink.accept runs on the thread that called sampleColumns, inline, once per column. If that thread is async, the sink must not touch Bukkit state. Collect locally, hop afterward.
Column sampling
sampleColumns walks a rectangle at a stride, chunk by chunk, into your sink.
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();
}
Bounds are inclusive on both ends. Lattice anchors at (minBlockX, minBlockZ) and steps by strideBlocks.
Constructor rejects with IllegalArgumentException:
- empty
fields, maxBlockX < minBlockXormaxBlockZ < minBlockZ,strideBlocks < 1.
fields is defensively copied on construction and on every fields() call. fields() allocates a fresh EnumSet each call — hoist it out of loops.
columnCount() and chunkCount() saturate at Long.MAX_VALUE on overflow.
Hard limits
maxSampleChunks = max(64, noiseCacheSize / 4)
maxSampleColumns = maxSampleChunks * 256 (capped at Integer.MAX_VALUE)
Default performance.noiseCacheSize is 1024 → 256 chunks and 65 536 columns. One API query may not consume more than a quarter of the live generator cache.
A query over either limit returns false and never calls the sink. No partial answer, truncation, exception, or log line.
Limits are independent. Example: stride 64 over a 6400×6400 block rectangle can pass the column limit and fail the chunk limit. chunkCount() is the chunk span of the rectangle, not sampled columns — stride does not reduce it. Tile large areas.
Ask maxSampleColumns() / maxSampleChunks() every time; they change when the operator edits settings and reloads.
The sink
@FunctionalInterface
public interface IrisColumnSink {
void accept(int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey);
}
Every column produces one accept. Placeholders for unrequested fields are not distinguishable from real data by value alone:
| Field requested | Parameter | If requested | If not |
|---|---|---|---|
SURFACE_HEIGHT |
surfaceHeight |
absolute world Y of topmost terrain | -1 |
SURFACE_KIND |
kind |
LAND, SHORE, OCEAN, or VOID |
IrisSurfaceKind.UNKNOWN |
BIOME_KEY |
biomeKey |
biome load key | null |
-1 is a legal absolute Y in worlds with negative min height — never treat -1 as absent. Branch on your field set. biomeKey may be null even when requested if the column has no biome.
Fewer fields cost less. SURFACE_KIND alone skips the biome stream for void-floor and at-or-below-fluid columns. BIOME_KEY pays for the biome stream every column.
Visit order
Columns arrive grouped by chunk. Chunk walk: Z outer, X inner. Within a chunk: lattice Z outer, X inner. Deterministic for a given query; not a pure row-major sweep of the rectangle. Sort or index by (blockX, blockZ) if you need raster order.
Return value
true iff every column was delivered. false when:
world,query, orsinkis null, or no live Iris engine — sink untouched;- a limit was exceeded — sink untouched;
- your sink threw — walk stops at that column;
- engine closed mid-walk — walk stops at that column.
In the last two cases, already-delivered columns stay delivered. Treat false as incomplete; discard partial results if completeness is required.
Worked example: flattest buildable spot
Async sample, then hop to the player's entity scheduler (correct on Paper and Folia).
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 sampleColumns caller thread.
The minimum: one coordinate
IrisTerrainService terrain = service();
String biome = terrain == null
? "unknown"
: terrain.surfaceBiomeName(player.getWorld(), player.getLocation().getBlockX(),
player.getLocation().getBlockZ()).orElse("unknown");
surfaceBiomeName returns empty for non-Iris worlds, null worlds, closing engines, or disabled Iris. Call isIrisWorld only when you need to distinguish "not Iris" from "Iris has no answer".
What IrisWorldInfo tells you
public record IrisWorldInfo(
String dimensionKey,
String worldIdentity,
long seed,
int minHeight,
int maxHeight,
int fluidHeight,
boolean studio) {
public int height();
}
| Component | Meaning |
|---|---|
dimensionKey |
Pack load key of the dimension (e.g. overworld) |
worldIdentity |
World's namespaced key as string (e.g. minecraft:overworld) |
seed |
Raw generator seed |
minHeight |
Absolute world floor Y (e.g. -64) |
maxHeight |
Absolute world ceiling Y, exclusive (e.g. 320) |
fluidHeight |
Absolute pack sea level Y (pack fluid height + minHeight) |
studio |
true only for a transient studio world |
height() |
maxHeight - minHeight |
All height fields are absolute world Y, comparable with surfaceHeight and blockY. Constructor rejects null dimensionKey/worldIdentity and non-positive height range.
worldIdentity is what Iris persists per-world state under. Outside the three vanilla dimensions the server derives the key from the world folder — renaming the folder changes worldIdentity and World#getName().
studio worlds exist briefly for pack authoring; skip them for persistence.
seed reproduces the entire world offline. Iris does not expose it via PlaceholderAPI (09 - PlaceholderAPI.md). Do not put it where players can read it.
Failure policy
| Situation | Behaviour |
|---|---|
world is null |
Queries answer absent; sampleColumns returns false |
| World has no Iris generator | Same |
| Iris disabled, or disabled between calls | Same; nothing throws |
| Generator closing, or engine closed | isIrisWorld still true; other queries absent |
| Query throws inside engine | Counted, logged with stack, answered absent |
query or sink null |
sampleColumns returns false |
| Query exceeds sample limits | false, sink never called, nothing logged |
| Sink throws | Walk aborts, fault logged (throttled), false; prior columns delivered |
| Engine closes mid-walk | Walk stops, false |
isIrisWorld does not check liveness. It answers "created by Iris", not "can answer right now". During unload/shutdown you can see isIrisWorld == true with empty worldInfo. Use Optional carefully.
No caller quarantine. Fault counters only throttle log lines to at most one report per minute per category; the count is cumulative.
No checked exceptions. Unchecked throws only from IrisColumnQuery / IrisWorldInfo construction validation.
Configuration
plugins/Iris/settings.json:
| Key | Default | Effect |
|---|---|---|
performance.noiseCacheSize |
1024 |
Shared noise cache chunk capacity. maxSampleChunks = max(64, this / 4); maxSampleColumns = maxSampleChunks * 256 |
No on/off switch for the terrain API. Answers for every world with a live Iris engine; absent otherwise.
Enum reference
IrisSurfaceKind
| Constant | Meaning | Test applied (engine space, then reported in absolute terms) |
|---|---|---|
LAND |
Dry ground | Surface above fluid height; biome not shore |
SHORE |
Beach or bank | Surface above fluid; pack classifies biome as shore |
OCEAN |
Under water / sea floor at sea level | Surface at or below fluid height (and above void floor) |
VOID |
Nothing generated | Engine surface height ≤ 0 → absolute surface ≤ minHeight() |
UNKNOWN |
No answer | Not Iris / unavailable / fault / SURFACE_KIND not requested |
VOID wins first. Then fluid check, then shore vs land. Mutually exclusive.
OCEAN is inclusive at fluid height. Compare surfaceHeight to fluidHeight yourself if the one-block boundary matters.
IrisColumnField
| Constant | Fills | Extra work |
|---|---|---|
SURFACE_HEIGHT |
surfaceHeight |
one height sample per column |
SURFACE_KIND |
kind |
height sample; biome only when above void floor and above fluid |
BIOME_KEY |
biomeKey |
biome sample per column, always |
SURFACE_HEIGHT and SURFACE_KIND share the height sample when both are requested.
Write a default arm when switching enums: 90 - API - Getting Started.md.