mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
424 lines
22 KiB
Markdown
424 lines
22 KiB
Markdown
# 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`.
|