Files
Iris/docs/91 - API - Terrain.md
T
2026-08-08 00:29:48 -06:00

18 KiB
Raw Blame History

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 < minBlockX or maxBlockZ < 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 1024256 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, or sink is 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.