Files
Iris/docs/api/modded.md
T
Brian Neumann-Fopiano a8217b42e9 F
2026-08-01 22:39:09 -04:00

28 KiB

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, world-events.md or 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 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:

./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:

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:

{
  "suggests": { "irisworldgen": "*" }
}

NeoForge (META-INF/neoforge.mods.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:

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:
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.

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:

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.


Native worldgen passthrough: what generates over Iris terrain

Iris replaces the chunk generator outright. Every piece of vanilla or mod worldgen therefore only runs if Iris runs it. This is the honest matrix.

Vanilla / mod worldgen Runs over Iris terrain? How
Structures (vanilla, datapack, mod) Yes, on by default Iris runs its own structure pass with vertical fitting, foundation stilts and vegetation clearing. Deny with importedStructures.disabled
Placed features: ores, trees, plants, springs, geodes (vanilla, datapack, mod) Yes, off by default Set importedFeatures.enabled on the dimension
Carvers (caves, canyons, mod carvers) Never Architectural. See below
Mod biomes Only as a derivative / vanillaDerivative / biomeScatter target Iris chooses biomes from the pack, not from a biome source
Mob spawning, including mod mobs Yes Iris merges the biome's own spawn table with the vanilla derivative's
Surface builders / surface rules Never Iris generates its own surface from the pack palettes

importedFeatures

A dimension-level control block, disabled by default. With it absent or enabled: false chunk output is byte-for-byte what Iris has always produced, and no feature table is built.

Biome tags are not part of that guarantee, and are not gated on this flag at all: Iris custom biomes inherit the biome tags of their vanilla derivative on every platform, so the emitted datapack tag files differ from older Iris builds regardless of importedFeatures. Anything driven by biome tags - mob variants, spawn rules, mod content selecting on #minecraft:is_overworld and friends - therefore applies to Iris custom biomes.

{
  "importedFeatures": {
    "enabled": true,
    "steps": ["UNDERGROUND_ORES"],
    "disabledSteps": ["VEGETAL_DECORATION"],
    "disabled": ["minecraft:ore_diamond", "minecraft:trees"]
  }
}
Field Meaning
enabled Master switch. Default false
steps Allow-list of decoration steps. Empty (default) means every step
disabledSteps Deny-list of decoration steps, applied after steps
disabled Placed-feature key deny-list. A namespace:path prefix matches on namespace, slash and underscore boundaries, so minecraft:ore denies every vanilla ore

Steps are the vanilla ones, in order: RAW_GENERATION, LAKES, LOCAL_MODIFICATIONS, UNDERGROUND_STRUCTURES, SURFACE_STRUCTURES, STRONGHOLDS, UNDERGROUND_ORES, UNDERGROUND_DECORATION, FLUID_SPRINGS, VEGETAL_DECORATION, TOP_LAYER_MODIFICATION. Ores live in UNDERGROUND_ORES; trees, grass and flowers in VEGETAL_DECORATION.

What you get, and what it costs:

  • Features are read from the biome's vanilla derivative. An Iris custom biome declares no features of its own by design (its generated datapack JSON has empty features and carvers arrays); passthrough comes from the chunk generator's generation-settings getter, which maps the custom biome onto the derivative.
  • Iris terrain is not vanilla terrain. A feature that assumes a vanilla surface can land oddly - floating sugar cane, ore veins in unexpected rock, trees on a slope Iris carved. Turn it on per dimension and look before shipping.
  • Feature seeds are derived exactly as vanilla derives them, so the same pack plus the same registries places the same features. Denying one feature never shifts another: each takes its seed from its own global index.
  • The pass runs on the worldgen thread that owns the chunk, never on the Iris generation pool. The vanilla FEATURES step writes into neighbouring chunks and is not parallel-safe.
  • The whole feature pass runs after Iris has placed its structures, not interleaved per step the way vanilla orders them. An early-step feature - RAW_GENERATION, LAKES, LOCAL_MODIFICATIONS - therefore sees placed structures and can cut into one, so a lake can open into a structure vanilla would have flooded before placing it.
  • Both platforms behave identically: the Bukkit plugin has the same control with the same semantics.

Feature order cycles. Vanilla topologically sorts every placed feature across every biome. Content that declares mutually inconsistent orderings makes that sort fail with Feature order cycle found. Iris builds the table at bind and catches that failure: importedFeatures degrades to off for the dimension and Iris logs an ERROR naming the involved sources. It never becomes a chunk-generation crash.

Why carvers can never be imported

A carver runs against NoiseGeneratorSettings - the noise router, aquifer state and surface rules of a NoiseBasedChunkGenerator. Iris has none of those; its terrain comes from the pack's own generators and its caves from the Iris carving system. There is nothing for a vanilla carver to sample, so applyCarvers is empty by design and there is no flag to change that. Use Iris caves and carvings in the pack instead.

26.2 pack-content note: pointed_dripstone and speleothem

26.2 renamed the feature type minecraft:pointed_dripstone to minecraft:speleothem, and minecraft:dripstone_cluster to minecraft:speleothem_cluster. Verified against the 26.2 built-in data:

Registry 26.2 key
Block (minecraft:block) minecraft:pointed_dripstone - unchanged
Placed feature (minecraft:worldgen/placed_feature) minecraft:pointed_dripstone - unchanged
Configured feature (minecraft:worldgen/configured_feature) minecraft:pointed_dripstone - unchanged
Feature type (minecraft:worldgen/feature) minecraft:speleothem - renamed

So a pack that lists minecraft:pointed_dripstone in a palette, an object, or an importedFeatures.disabled entry is still correct - those are block and placed-feature keys. Only content that names the feature type directly, which is a datapack-authoring concern rather than an Iris pack concern, needs updating.

Biome tags

Generated Iris custom biomes inherit the biome tag membership of their vanilla derivative, on top of any tags the pack declares in tags. That is what makes #minecraft:is_overworld and mod-authored tag selectors resolve against Iris terrain; without it a custom biome sits in no tag at all. Structure tags (#minecraft:has_structure/*) are deliberately not inherited - Iris resolves native structure placement through the biome's structure derivative, so inheriting them would place a structure twice.

Tag files are written with "replace": false, so vanilla tags are extended, never replaced.


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 is Bukkit-only.
  • Datapack ingest is Bukkit-only, per the command note above.
  • Vanilla carvers and surface rules never run. See the passthrough matrix 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.