This commit is contained in:
Brian Neumann-Fopiano
2026-07-30 12:31:39 -04:00
parent 0afbfcb7a2
commit 324cf9e095
374 changed files with 22705 additions and 25650 deletions
+423
View File
@@ -0,0 +1,423 @@
# Iris on Fabric, Forge and NeoForge
`art.arcane.iris.modded.api` is the surface a **mod** compiles against. It answers three questions: is this
level generated by Iris, how do I drive Iris from my mod, and how do I get my own blocks, items and mobs
placed by an Iris pack.
It ships in the Fabric, Forge and NeoForge jars only. It is absent from the Bukkit plugin jar, shares no types
with `art.arcane.iris.api`, and is not covered by [terrain.md](terrain.md), [world-events.md](world-events.md)
or [tree-feller.md](tree-feller.md) — those describe the Bukkit surface, which does not exist on a mod loader.
Everything here assumes Minecraft 26.2, Java 25, and one of Fabric, Forge or NeoForge. The mod id is
`irisworldgen` on all three.
| What you want | Where |
|---|---|
| Detect Iris, read the engine, start a pregeneration, read/write mantle data | `IrisModdedAPI` |
| Have Iris place *your* blocks, items and mobs | `ModdedDataProvider` |
| Alias one custom key onto a fixed vanilla state, with no provider class | `IrisModdedAPI.registerCustomBlockData` |
---
## Depending on Iris
**There is no published Maven artifact for the mod jars.** No module in this repository applies
`maven-publish`, and the JitPack route documented in [README.md](README.md) resolves the Bukkit sources, not
the modded adapter. Until that changes, building from source is the only path.
The three adapters are standalone Gradle builds — each `adapters/<loader>/settings.gradle` does
`includeBuild('../..')` to substitute `art.arcane:core` and `art.arcane:spi` from the root build, which is what
keeps Loom, ForgeGradle and ModDevGradle off one plugin classpath. The root build drives them through their own
wrappers:
```bash
./gradlew buildFabric # -> dist/Iris v<version> [Fabric] <mc>+<loader>.jar
./gradlew buildForge # -> dist/Iris v<version> [Forge] <mc>+<loader>.jar
./gradlew buildNeoforge # -> dist/Iris v<version> [NeoForge] <mc>+<loader>.jar
```
Then compile against the jar you will actually run:
```gradle
dependencies {
compileOnly(files('libs/Iris-fabric.jar'))
}
```
The adapters are **not** in the root `settings.gradle` by default. Add `-PincludeModdedAdapters=true` for IDE
import only; it closes a composite build cycle (root -> adapter -> root) and Gradle may reject it.
### Soft dependency
Declare the optional relationship, then do not rely on load order — the ServiceLoader path below works
regardless of it, because Iris does the discovering.
Fabric (`fabric.mod.json`) — `suggests`, not `depends`; a hard `depends` makes Iris mandatory:
```json
{
"suggests": { "irisworldgen": "*" }
}
```
NeoForge (`META-INF/neoforge.mods.toml`):
```toml
[[dependencies.yourmod]]
modId = "irisworldgen"
type = "optional"
ordering = "AFTER"
side = "BOTH"
```
Forge (`META-INF/mods.toml`) is the same with `mandatory = false` instead of `type`.
### Detecting Iris
Two checks, and they answer different questions.
**Is the mod present?** Ask the loader — `FabricLoader.getInstance().isModLoaded("irisworldgen")`, or
`ModList.get().isLoaded("irisworldgen")` on Forge and NeoForge. Cheap, but tells you nothing about whether
Iris actually generates anything.
**Are Iris classes on the classpath?** If your integration lives in a class that imports
`art.arcane.iris.modded.api.*`, that class must not load unless Iris is present. Keep the imports behind a
presence check and a separate class, or probe reflectively:
```java
private static final boolean IRIS_PRESENT = irisPresent();
private static boolean irisPresent() {
try {
Class.forName("art.arcane.iris.modded.api.IrisModdedAPI");
return true;
} catch (Throwable absent) {
return false;
}
}
```
Do not gate on a version string. Probe for the class or method you need.
**Is *this* level Iris?** `IrisModdedAPI.isIrisLevel(level)`. A server can mix Iris and vanilla dimensions
freely, so presence of the mod is not presence of an Iris world.
---
## `IrisModdedAPI`
All static, all null-tolerant: a null or non-Iris `ServerLevel` yields `false`, `null` or a no-op. You never
have to pre-check.
| Method | What it does |
|---|---|
| `isIrisLevel(ServerLevel)` | Whether the level's chunk generator is `IrisModdedChunkGenerator`. The cheapest check |
| `isStudioLevel(ServerLevel)` | Whether it is a throwaway pack-authoring world. Persist nothing against one |
| `getEngine(ServerLevel)` | The `Engine` behind the level, or null. See the stability warning below |
| `pregenerate(ServerLevel, int radiusBlocks)` | Starts a cached async pregeneration around the origin |
| `pregenerate(ServerLevel, int, int centerX, int centerZ, boolean sync, boolean cached)` | Same, with a centre and write mode |
| `getMantleData(ServerLevel, int x, int y, int z, Class<T>)` | Reads Iris's per-block sidecar storage |
| `setMantleData(ServerLevel, int x, int y, int z, T)` | Writes it |
| `deleteMantleData(ServerLevel, int x, int y, int z, Class<T>)` | Removes a value of that type |
| `retainMantleDataForSlice(Class<?>)` | Declares a mantle type Iris must keep rather than discard |
| `registerProvider(ModdedDataProvider)` | Registers a custom content provider imperatively |
| `registerCustomBlockData(String namespace, String key, String state)` | Aliases one key onto a fixed block state |
### `Engine` is internal
`getEngine` returns `art.arcane.iris.engine.framework.Engine`. That type is **internal to Iris** and changes
without a deprecation cycle — as do `art.arcane.iris.core.*`, `art.arcane.iris.util.*` and
`art.arcane.iris.spi.*`. Treat the returned `Engine` as an opaque token to hand back to Iris. Every method in
the table above that needs one resolves it for you; prefer those.
Never cache an `Engine`. A pack hotload or a level unload replaces it, and the old instance goes inert.
`getEngine` already returns null while the generator is binding and during shutdown.
### Pregeneration
`pregenerate` returns as soon as the job is queued. Progress goes to Iris's own logging and boss bar, not to
your caller. Only one job runs server-wide, so it returns `false` if one is already active — and `false` also
means "not an Iris level", so check `isIrisLevel` first if you need to tell those apart. Call it on the server
thread.
`cached = true` writes an on-disk pregeneration cache so an interrupted run resumes instead of regenerating.
`sync = true` writes chunks synchronously: slower, but it bypasses the async write queue.
### Mantle data
The mantle is Iris's own per-block storage, independent of chunk NBT, and it is how Iris carries data between
generation stages. Three things to know:
1. **Coordinates are world-space.** `y` is translated by the engine's minimum height internally. A `y` outside
the engine's height range reads as null and writes as a no-op — no exception.
2. **Reads never create storage; writes do.** `getMantleData` returns null when no mantle region exists for
that column yet. `setMantleData` and `deleteMantleData` create the region, which can touch disk — do not
call them per block in a tick loop on the server thread.
3. **Declare your types or lose them.** Iris discards mantle slices it does not need once a region's
generation data has served its purpose. Any type you write and expect to read back later must be declared
once during mod setup:
```java
IrisModdedAPI.retainMantleDataForSlice(MyMarker.class);
```
Registration is by canonical class name, process-wide across every Iris world, and cannot be undone. All three
mantle methods throw `IllegalStateException` if the engine's mantle has already been closed.
---
## `ModdedDataProvider`
The extension point. Implement it and a pack can name `yourmod:something` in a block palette, a loot table or a
spawn entry, and Iris will ask you to resolve it.
```java
public interface ModdedDataProvider {
String modId();
default boolean isReady(); // default true
Collection<Identifier> getTypes(ModdedDataType type);
boolean isValidProvider(Identifier id, ModdedDataType type);
default ModdedBlockData getBlockData(Identifier blockId, Map<String, String> state); // default null
default void processBlockPlacement(ModdedBlockPlacementContext context); // default no-op
default Entity spawnMob(ServerLevel level, double x, double y, double z, Identifier entityId); // default null
default void init(); // default no-op
}
```
`ModdedDataType` is `BLOCK`, `ITEM` or `ENTITY`. Constants may be added — write a `default` arm in any switch
expression over it.
### The contract
`modId()` is your identity, not decoration. It de-duplicates registrations and labels every log line Iris emits
about your provider. It must be non-null and stable; returning null aborts discovery.
`isValidProvider(id, type)` is the gate. Iris calls it before every resolution callback, on generation threads,
for every key it could not resolve itself. Keep it to a namespace comparison or a set lookup.
`isReady()` is how a provider whose registries populate late excuses itself. Iris **skips** a provider that
returns false rather than treating it as absent, so returning false is strictly better than returning wrong
answers.
`getTypes(type)` feeds command suggestion and pack tooling. It is not the resolution path — return an empty
collection rather than null, and do not do work here that `isValidProvider` should do.
`getBlockData(blockId, state)` resolves a claimed block. `state` holds the `[prop=value]` pairs from the pack's
key, already parsed, possibly empty, never null. Return null to decline and Iris tries the next provider, then
falls back to air. Return `ModdedBlockData.direct(blockState)` when the state is final.
`processBlockPlacement(context)` finishes a **deferred** placement. Return
`ModdedBlockData.deferred(placeholder)` from `getBlockData` when the real block needs a loaded level — a block
entity, neighbour state, or mod registries not reachable from a generation thread. Iris writes your placeholder
during generation and calls you back later on the server thread with the chunk loaded. Pick a placeholder with
the same shape and occlusion as the final block so terrain around it generates correctly. Only the *first*
provider claiming the identifier is called for a given position.
`ModdedBlockPlacementContext` is an immutable record: `engine`, `level`, `position`, `blockId`, `state`,
`blockState`. `blockState` is what is currently at `position` — normally your placeholder, though a later
generation stage may have replaced it. `state` is defensively copied and unmodifiable.
`spawnMob(...)` spawns a claimed custom entity on the server thread. Return null to decline.
`init()` runs once, immediately after Iris accepts your provider.
### Threading
| Callback | Thread | Notes |
|---|---|---|
| `isValidProvider`, `getBlockData` | Generation threads, many at once | Must be fast. Must not touch world state |
| `processBlockPlacement` | Server thread, chunk loaded | Safe to write blocks and attach block entities |
| `spawnMob` | Server thread | |
| `init` | Whichever thread registered you | Mod init for ServiceLoader, your caller otherwise |
| `modId`, `isReady`, `getTypes` | Any | |
Implementations must be thread-safe. `getBlockData` in particular is called concurrently for every unresolved
key a pack names, which during a pregeneration is a lot.
### Registration by ServiceLoader
Iris discovers providers with `java.util.ServiceLoader`. Ship a service file naming your implementation's
binary name; the class needs a public no-argument constructor.
`src/main/resources/META-INF/services/art.arcane.iris.modded.api.ModdedDataProvider`:
```
com.example.yourmod.iris.YourIrisProvider
```
One binary name per line. Nested classes use `$`, for example
`com.example.yourmod.YourMod$IrisProvider`.
Iris loads the service with **its own** class loader:
`ServiceLoader.load(ModdedDataProvider.class, ModdedCustomContentRegistry.class.getClassLoader())`. Your service
file therefore has to be visible from Iris's loader, which is the normal case on all three loaders but is the
thing to suspect first if nothing happens. Confirm with the registration log line below; if it never appears,
fall back to `registerProvider`.
### When discovery runs
`ModdedCustomContentRegistry.discover()` runs inside `ModdedEngineBootstrap.bootCommon(...)`, which is the very
first thing each loader's entrypoint calls — `IrisFabricBootstrap.onInitialize`,
`IrisForgeBootstrap`/`IrisNeoForgeBootstrap` construction. That is **before** the Iris chunk generator is
registered and long before any server starts. Consequences:
- A ServiceLoader-declared provider is available before any world could resolve a block. This is the safe path.
- Discovery runs **once per process**. A second `discover()` is a no-op.
- Your `init()` must not assume a server, a level, or a fully populated game registry. Defer that work and gate
it behind `isReady()`.
### Registration imperatively
`IrisModdedAPI.registerProvider(provider)` works at any time and is the option if you would rather not ship a
service file, or need to build the provider from your own config.
Ordering is the catch: Iris only consults providers registered **before** a pack resolves the block in
question, and blocks already resolved are not revisited. Register during mod setup. Registering after Iris's
own ServiceLoader pass is fine; registering after a world has generated is not.
A second registration under a `modId()` already present is logged and ignored. `init()` runs during the call.
### How discovery and failures are reported
Everything below is logged under the `Iris` logger. One line per accepted provider confirms registration —
this is what to grep for when checking whether your service file was seen:
```
Iris registered custom content provider 'yourmod'
```
A duplicate `modId()` is rejected with `already registered; ignoring duplicate`.
Iris catches throwables from every provider callback, logs them against your `modId()`, and continues with the
remaining providers — one broken provider does not stop world generation:
```
Iris custom content provider 'yourmod' failed resolving block yourmod:thing
Iris custom content provider 'yourmod' failed post-placement for yourmod:thing at BlockPos{...}
Iris custom content provider 'yourmod' failed spawning mob yourmod:critter
Iris custom content provider 'yourmod' failed to initialize # registerProvider path only
```
`init()` during ServiceLoader discovery is the one exception, and it is all-or-nothing: a throwable there
aborts the pass, restores the registry to its pre-discovery state, and rethrows. The log line names the
provider that failed, by mod id and class name:
```
Iris custom content provider discovery failed at provider 'yourmod' (com.example.yourmod.iris.YourIrisProvider)
```
If your provider's own `modId()` throws while Iris is building that message, the class name alone is logged; if
the failure happened outside any provider, it reads `the provider service loader`. A `null` provider or a null
`modId()` from the ServiceLoader fails the pass with a message naming which.
### Static aliases, no provider class
For the common case of "my key is really this vanilla block", skip the provider:
```java
IrisModdedAPI.registerCustomBlockData("yourmod", "fancy_log", "minecraft:oak_log[axis=y]");
```
The state string uses the same syntax packs use and is parsed **immediately** — a typo is logged at startup and
the registration dropped, rather than surfacing later as missing blocks. Aliases take precedence over provider
lookups for the same key. Null arguments are ignored.
---
## How pack resolution works on modded
Paths are relative to the loader's config directory (`config/` on a normal server install).
| Path | What it is |
|---|---|
| `config/irisworldgen/packs/<pack>/` | Installed packs. A pack is valid when `dimensions/<dimension>.json` exists |
| `config/irisworldgen/generated/datapack/iris/` | The generated forced datapack. Iris owns this — do not edit it |
| `config/irisworldgen/modded.json` | Mod-side config: default pack, auto-download, primary world routing |
| `config/iris/` | Engine data directory: settings and per-world engine state |
Note the split: the engine's data folder is `config/iris`, but every modded pack path — installer, validator,
command suggestions, forced datapack, engine creation — resolves under `config/irisworldgen/packs`. Install
packs there.
At `bootCommon`, Iris kicks off an async default-pack prefetch. If `modded.json` has
`autoDownloadDefaultPack` enabled and the configured `defaultPack` is missing, Iris downloads
`IrisDimensions/<pack>` from the `master` branch into the packs folder. If that fails it logs a pointer to
`/iris download <pack>`. A pack that is already installed is left alone.
When a level asks for its pack, `ModdedWorldEngines.packFolder(pack)` resolves
`config/irisworldgen/packs/<pack>`. A missing pack is a hard failure with the expected absolute path printed —
Iris does not silently generate vanilla terrain in its place.
### The forced datapack
Iris cannot register dimension types and per-pack biomes through a mod registry, because vanilla world creation
reads them from the datapack layer. So Iris **generates a datapack** from the installed packs and injects it as
a built-in, top-priority repository source:
- Fabric: a `PackRepository` mixin, via `FabricForcedDatapackSources`
- Forge and NeoForge: `event.addRepositorySource(ModdedForcedDatapack.repositorySource())`
It contributes world presets, dimension types and biomes under the `irisworldgen` namespace, with ids derived
from the pack and dimension names — `irisworldgen:packs/<pack>/dimensions/<dimension>/preset`,
`.../dimension_type`, `.../biomes/<biome>`. This is why an Iris dimension shows up in the vanilla world
creation screen as `IRIS:<Pack>`.
It is regenerated when the pack changes: a studio hotload calls `ModdedForcedDatapack.regenerate()`. If
regeneration fails and a previously published pack is still readable, Iris keeps the last known-good one and
logs the failure rather than starting with no dimension types.
**The failure you need to recognise.** If injection did not happen for your loader — a mixin that failed to
apply, an event that never fired — Iris logs this once at startup and world creation will fail no matter how
many times you restart:
```
Iris forced datapack 'iris_worldgen' was never loaded by this server.
N installed pack(s) at <path> contributed no dimension types or custom biomes.
Datapack source injection failed for this loader (mixin/event not applied), so world
creation will fail and restarting will not fix it.
```
That is a loader/environment problem, not a pack problem. Nothing an integrating mod does can fix it.
### Commands worth knowing
The modded command tree is `/iris`, aliased `/ir` and `/irs`, gated at gamemaster permission level.
| Command | What it tells you |
|---|---|
| `/iris pack validate [pack]` | Validates every installed pack, or one. Runs on a worker thread, reports per pack, and counts unloadable packs |
| `/iris pack status [pack]` | Replays the **recorded** validation results — blocking errors and warnings per pack. Says so and returns nothing if `validate` has not run this session |
| `/iris pack cleanup <pack> [apply]` | Previews unused pack resources; `apply` deletes them |
| `/iris pack restore <pack> [apply]` | Previews a restore of pack resources; `apply` performs it |
| `/iris datapack status` | Per Iris dimension: active dimension type, its min/max/logical height, what the pack wants, and whether they match |
| `/iris datapack install` | Writes the pack's dimension type into `<world>/datapacks/iris/data/irisworldgen/dimension_type/` as an override |
| `/iris datapack list` | Datapack URLs declared by installed pack dimensions, plus the datapacks actually present in `<world>/datapacks/` |
| `/iris download <pack> [branch] [overwrite]` | Installs a pack from `IrisDimensions/<pack>` into the packs folder. Aliased `dl` |
| `/iris version` | Iris version and loader |
`/iris datapack status` is the first thing to run when an Iris dimension generates at the wrong height. A
mismatch means the level's active dimension type disagrees with the pack, which happens when a world was
created before a pack's height range changed. `install` writes the override; the world still needs a restart.
`/iris datapack ingest`, `pull` and `remove` exist but refuse on modded, with a message explaining why: the
Modrinth ingest workflow is Bukkit tooling. Native vanilla and datapack structure placement **does** work on
modded — install the datapack into `<world>/datapacks/` and restart, and its registered structures generate.
---
## What is not supported
- **No published artifact.** Build from source, as above. There is no Maven coordinate for the modded jars and
no `maven-publish` in this build.
- **Core types are internal.** `art.arcane.iris.engine.*`, `art.arcane.iris.core.*`,
`art.arcane.iris.util.*` and `art.arcane.iris.spi.*` change without notice. `Engine`, reachable through
`getEngine` and `ModdedBlockPlacementContext.engine()`, is the one internal type this surface exposes, and it
is exposed as a token to hand back rather than an API to call.
- **No event bus.** `IrisPlatform.callEvent` is a no-op on every mod loader adapter. There is no modded
equivalent of `IrisWorldEngineEvent` or `IrisPregenerationEvent`; poll `isIrisLevel`/`getEngine` instead.
- **No `art.arcane.iris.api`.** The Bukkit terrain, world-event, pregen and tree-feller interfaces are not in
the mod jars. There is no modded terrain-query surface yet.
- **No PlaceholderAPI.** [placeholders.md](placeholders.md) is Bukkit-only.
- **Datapack ingest is Bukkit-only**, per the command note above.
- **`ModdedCustomContentRegistry`'s resolution methods are Iris internals.** They are public only because the
adapter's generation code lives in another package. Go through `IrisModdedAPI`.