Updated Docs, and Cortections

This commit is contained in:
Brian Neumann-Fopiano
2026-08-08 00:29:48 -06:00
parent c40e1cf152
commit 506787f51a
70 changed files with 9485 additions and 3050 deletions
+435
View File
@@ -0,0 +1,435 @@
# 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.
```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();
}
}
```
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
```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 `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.
```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();
}
```
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 `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
```java
@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).
```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 `sampleColumns` caller thread.
---
## The minimum: one coordinate
```java
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
```java
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`.