Merge feature/version-compat: gate pack content on the running Minecraft version

- Every registrant is evaluated on load (IrisData.preprocessObject ->
  IrisRegistrant.evaluateCompat) by a cached per-class reflective walker over the
  annotated key fields; the verdict is stored on the registrant and pool sites filter
  with isCompatExcluded(). Block keys resolve through the live registry, the legacy
  rename table, IrisDimension.blockFallbacks and the entry backup, in that order.
- Unit of exclusion is the compat unit that composes the missing content (registrant
  or object placement). A referencing unit drops the reference; an emptied required
  pool excludes the container (CompatPools cascade through regions, biomes, dimension,
  spawners, loot tables, jigsaw pools and structures). A cascade that reaches the
  dimension is a blocking validation error and world creation is refused.
- Object placements are evaluated per object from the .iob palette header: an object
  is excluded from a placement's pool unless a chance-1 type-replace (edit rule
  matching the key) or a fallback covers the key. edit find lists and marker mark
  lists are match-only. Missing palette keys stay MissingBlockState placeholders
  (air everywhere, real air nativeHandle) so a chance-1 edit rule can still rewrite
  them at placement time.
- Approved decisions: exclude the composing unit; console-only reporting; no version
  fields on packs and no cross-version pre-check (authors build on their version,
  older servers gate at load).
- IrisDimension.blockFallbacks (base key -> full block state) applies to every
  palette, decorator, deposit, replace rule and object in the pack.
- IrisBlockData.backup is revived on every platform (it was dead on Bukkit because
  B.getState returned air).
- The legacy rename table now lives in LegacyBlockRenames and is applied identically
  on Bukkit and the mod loaders.

Operator surface
- Boot listing per pack after the validation lines (IrisWorldGeneratorResolver on
  Bukkit, ModdedStartup on modded), grouped by key, capped at three subjects per key.
- Validation summary line on /iris pack validate and /iris pack status, findings
  persisted in PackValidationResult through PackValidationCache.
- One summary line per world engine when its pack has findings.
- /iris pack compat [pack] (alias cp) on Bukkit and modded, uncapped, from the
  persisted validation so no pack is reloaded; catalog keys in all 17 locales.

Verification on the branch
- Full build green in the .compatwt worktree; modded adapters compile.
- Golden hash unchanged on Paper 26.2, seed 1337, radius 8.
- Live boots on Paper 26.1.2 and 26.2: the stock Overworld lists 6 unavailable keys
  on 26.1.2 and none on 26.2; the world creates and generates without the excluded
  content on 26.1.2.

Integration on top of master (df34f328e..fc3a26dd9)
- Conflicts: PackValidator keeps both import sets (JSONObject for static object
  validation plus the compat and loader types); RiverPolicyResolver.State.apply takes
  the confinement scope and the pack data (confined rivers and gate-filtered river
  biome selections); IrisRiverPolicy keeps shoreBiomeWidth and confined next to
  compatBiomes.
- Static objects honour the gate. The per-object palette evaluation moved out of
  IrisObjectPlacement into the shared IrisObjectCompat helper; IrisStaticObject
  .evaluateCompat mirrors a one-object placement, records findings with subject type
  "static object" and the object key (DROPPED for uncovered palette keys, EXCLUDED
  for a missing edit replace palette), IrisStaticObjectLayer.compile skips gated
  entries instead of failing, and PackValidator.gateContent asks every dimension for
  its static objects (IrisDimension.evaluateStaticObjectsCompat) so the boot listing
  is complete. CompatWalker treats IrisStaticObject like IrisObjectPlacement: never
  descended, so a static object's edit palette excludes only that entry, never the
  dimension. Covered by IrisStaticObjectCompatTest.
- The modded compat and cp subcommands run inside ModdedCommandTree.localized like
  the rest of the pack tree and are documented in the modded help; the parity test
  asserts both.
- Locale keys stay in core/src/main/resources/languages: master's on-demand download
  reads those files from the repository and the build only keeps them out of the jar,
  so the compat keys sit next to the debugdump keys in the same form.
- Placeholder audit of master's new code: the only native casts added are the
  isFaceSturdy fallbacks in IrisSpeleothems and ModdedDecoratorHooks, both behind
  B.isAir guards, and MissingBlockState.nativeHandle is the real air anyway;
  IrisObjectTransforms only asks B.isAir; decorators resolve palettes through
  IrisBlockData.getBlockData, which never yields a placeholder. No new guards needed.
- core/purity-allowlist.txt gains IrisSpeleothems: 5053a644c split the Bukkit
  isFaceSturdy fallback out of the already allowlisted DecoratorCore without moving
  the allowlist entry, so master's own build failed the Bukkit purity ratchet.
- CompatTestPlatform states now answer isAir, isSolid, isFluid and materialKey like a
  real adapter: B caches the first registries it sees per JVM, so an interned mock
  air that never said isAir leaked into the core.compat tests once the static layer
  ran under that platform.

Gate commands on the merged tree
- ./gradlew build (default VolmLib composite, ../VolmLib at 7b8029e): BUILD SUCCESSFUL;
  core 3499 (8 skipped) tests, bukkit plugin 343 tests, nms v26_2_R1 255 (1 skipped) tests,
  probe 78 tests, buildSrc 10 tests, 0 failures.
- cd adapters/fabric && ../../gradlew compileJava: BUILD SUCCESSFUL (:compileJava executed)
- cd adapters/forge && ../../gradlew compileJava: BUILD SUCCESSFUL (:compileJava executed)
- cd adapters/neoforge && ../../gradlew compileJava: BUILD SUCCESSFUL (:compileJava executed)
- cd adapters/forge && ../../gradlew test --tests IrisModdedCommandParityTest --tests
  ModdedLanguageCommandsTest: BUILD SUCCESSFUL, 11 tests, 0 failures.
This commit is contained in:
Brian Neumann-Fopiano
2026-09-03 10:48:37 -04:00
112 changed files with 8037 additions and 295 deletions
@@ -60,6 +60,7 @@ import java.util.function.Supplier;
*/
public final class IrisWorldGeneratorResolver {
private static final int VALIDATION_STABILITY_ATTEMPTS = 2;
private static final int COMPAT_BOOT_KEY_CAP = 3;
private static final Object SNAPSHOT_VALIDATION_LOCK = new Object();
private static final String IRIS_DIMENSION_NAMESPACE = "iris";
private static final String PLOT_SQUARED_DISCOVERY_WORLD = "CheckingPlotSquaredGenerator";
@@ -123,6 +124,9 @@ public final class IrisWorldGeneratorResolver {
if (packDirectory != null && packFingerprint != null && !packFingerprint.isBlank()) {
PackValidationRegistry.publish(packDirectory.toPath(), result, packFingerprint);
}
String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null;
String compatSummary = PackValidator.compatSummary(result, minecraftVersion);
String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary;
if (!result.isLoadable()) {
Iris.error("Pack '" + result.getPackName()
+ "' FAILED validation - world and Studio creation with this pack will be refused. Reasons:");
@@ -131,12 +135,22 @@ public final class IrisWorldGeneratorResolver {
}
} else if (!result.getWarnings().isEmpty()) {
Iris.info("Pack '" + result.getPackName() + "' validated ("
+ result.getWarnings().size() + " warning(s)).");
+ result.getWarnings().size() + " warning(s))." + compatSuffix);
for (String warning : result.getWarnings()) {
Iris.warn(" [" + result.getPackName() + "] " + warning);
}
} else if (cached.isEmpty()) {
Iris.success("Pack '" + result.getPackName() + "' validated.");
if (compatSummary.isEmpty()) {
Iris.success("Pack '" + result.getPackName() + "' validated.");
} else {
Iris.info("Pack '" + result.getPackName() + "' validated." + compatSuffix);
}
} else if (!compatSummary.isEmpty()) {
Iris.info("Pack '" + result.getPackName() + "' validated." + compatSuffix);
}
// Cache hits carry the persisted findings, so the listing prints on every boot, not only after a miss.
for (String line : PackValidator.compatBootLines(result, minecraftVersion, COMPAT_BOOT_KEY_CAP)) {
Iris.warn(line);
}
}
IrisStartupValidation.markPacksReady();
@@ -19,6 +19,7 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.Iris;
import art.arcane.iris.core.compat.PackCompatReport;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackResourceCleanup;
import art.arcane.iris.core.pack.PackValidationCache;
@@ -34,10 +35,13 @@ import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import art.arcane.volmlib.util.localization.TextKey;
import art.arcane.iris.spi.IrisPlatforms;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.BukkitCommandMessages;
@@ -250,6 +254,76 @@ public class CommandPack implements DirectorExecutor {
reportResult(s, result);
}
@Director(description = "Show pack content that cannot generate on this Minecraft version", descriptionKey = "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version", aliases = {"cp"})
public void compat(
@Param(description = "The pack folder name to report on, or * for every pack", descriptionKey = "iris.director.commandpack.param.pack_folder_name_compat", defaultValue = "*")
String pack
) {
VolmitSender s = sender();
if (wantsAllPacks(pack)) {
Map<String, PackValidationResult> snapshot = PackValidationRegistry.snapshot();
if (snapshot.isEmpty()) {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_VALIDATION_RESULTS_RECORDED_RUN_IRIS_PACK_VALIDATE_FIRST));
return;
}
for (Map.Entry<String, PackValidationResult> entry : snapshot.entrySet()) {
for (String line : compatOutput(entry.getKey(), entry.getValue())) {
s.sendMessage(line);
}
}
return;
}
for (String line : compatOutput(pack, PackValidationRegistry.get(pack))) {
s.sendMessage(line);
}
}
/**
* The compat listing for one pack, straight from the persisted validation so no pack is reloaded. Every key is
* printed with every subject; the boot listing is the capped form of the same data.
*/
static List<String> compatOutput(String pack, PackValidationResult result) {
List<String> lines = new ArrayList<>();
if (result == null) {
lines.add(IrisLanguage.text(
BukkitRuntimeMessages.COMMAND_PACK_NO_VALIDATION_RESULT_RUN_IRIS_PACK_VALIDATE,
MessageArgument.untrusted("pack", String.valueOf(pack)),
MessageArgument.untrusted("pack2", String.valueOf(pack))));
return lines;
}
String version = compatVersion(result);
if (result.getCompatFindings().isEmpty()) {
lines.add(IrisLanguage.text(
BukkitRuntimeMessages.COMMAND_PACK_COMPAT_NONE,
MessageArgument.untrusted("pack", pack),
MessageArgument.untrusted("version", version)));
return lines;
}
lines.add(IrisLanguage.text(
BukkitRuntimeMessages.COMMAND_PACK_COMPAT_HEADER,
MessageArgument.untrusted("pack", pack),
MessageArgument.untrusted("version", version)));
for (String line : PackCompatReport.of(result.getCompatFindings()).keyLines(0)) {
lines.add(IrisLanguage.text(
BukkitRuntimeMessages.COMMAND_PACK_MESSAGE_2,
MessageArgument.untrusted("value", line)));
}
lines.add(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_COMPAT_REMEDY));
return lines;
}
/** The live Minecraft version, falling back to the one the validation ran against. */
static String compatVersion(PackValidationResult result) {
if (IrisPlatforms.isBound()) {
String live = IrisPlatforms.get().minecraftVersion();
if (live != null && !live.isBlank()) {
return live;
}
}
String persisted = result.getMinecraftVersion();
return persisted == null || persisted.isBlank() ? "unknown" : persisted;
}
private PackValidationResult runValidate(VolmitSender s, File packFolder) {
try {
PackValidationResult result = PackValidator.validate(packFolder);
@@ -0,0 +1,94 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.volmlib.util.director.compat.DirectorAnnotationCompatibility;
import art.arcane.volmlib.util.director.runtime.DirectorNodeDescriptor;
import art.arcane.volmlib.util.director.runtime.DirectorParameterDescriptor;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class CommandPackCompatTest {
private IrisPlatform previousPlatform;
/** The persisted Minecraft version is only used when no platform is bound, so pin that here. */
@Before
public void unbindPlatform() {
previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
IrisPlatforms.unbind();
}
@After
public void rebindPlatform() {
IrisPlatforms.unbind();
if (previousPlatform != null) {
IrisPlatforms.bind(previousPlatform);
}
}
private static CompatFinding block(CompatAction action, String subjectKey) {
return new CompatFinding(
CompatRegistry.BLOCK, "minecraft:sulfur", action, "Biome", subjectKey, "layers[0].palette[1]");
}
private static PackValidationResult result(List<CompatFinding> findings) {
return new PackValidationResult("overworld", List.of(), List.of(), 1L, findings, "26.1.2");
}
@Test
public void unknownPackIsReportedWithoutAnyCompatLines() {
List<String> lines = CommandPack.compatOutput("nope", null);
assertEquals(1, lines.size());
assertTrue(lines.getFirst(), lines.getFirst().contains("No validation result for 'nope'"));
}
@Test
public void packWithoutFindingsSaysSoAndNamesTheVersion() {
List<String> lines = CommandPack.compatOutput("overworld", result(List.of()));
assertEquals(1, lines.size());
assertTrue(lines.getFirst(), lines.getFirst().contains("Pack 'overworld' uses no content"));
assertTrue(lines.getFirst(), lines.getFirst().contains("26.1.2"));
}
@Test
public void findingsArePrintedUncappedWithTheFallbackRemedy() {
List<String> lines = CommandPack.compatOutput("overworld", result(List.of(
block(CompatAction.EXCLUDED, "cave/a"),
block(CompatAction.EXCLUDED, "cave/b"),
block(CompatAction.EXCLUDED, "cave/c"),
block(CompatAction.EXCLUDED, "cave/d"))));
assertEquals(3, lines.size());
assertTrue(lines.get(0), lines.get(0).contains("Pack 'overworld': content unavailable on Minecraft 26.1.2"));
assertTrue(lines.get(1), lines.get(1).contains("minecraft:sulfur (block)"));
assertFalse("the command prints every subject", lines.get(1).contains("more"));
assertTrue(lines.get(1), lines.get(1).contains("cave/d"));
assertTrue(lines.get(2), lines.get(2).contains("blockFallbacks"));
}
@Test
public void compatIsInvocableWithoutAPackArgument() throws Exception {
DirectorNodeDescriptor node = DirectorAnnotationCompatibility
.fromMethod(CommandPack.class.getDeclaredMethod("compat", String.class))
.orElseThrow();
DirectorParameterDescriptor pack = node.getParameters().getFirst();
assertEquals("pack", pack.getName());
assertFalse("compat must not require a pack", pack.isRequired());
assertFalse("Director needs a non-blank default to treat the parameter as optional",
pack.getDefaultValue().isBlank());
}
}
@@ -39,7 +39,9 @@ public final class ModdedDecoratorHooks implements DecoratorPlatformHooks.FaceFi
@Override
public PlatformBlockState fixFaces(PlatformBlockState state, Hunk<PlatformBlockState> hunk, int rX, int rZ, int x, int y, int z, EngineMantle mantle) {
ModdedBlockState fabric = (ModdedBlockState) state;
if (!(state instanceof ModdedBlockState fabric)) {
return state;
}
BlockState cloned = fabric.handle();
Map<String, BooleanProperty> allowed = faceProperties(cloned);
@@ -25,6 +25,7 @@ import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator;
import art.arcane.iris.modded.command.ModdedPackCommands;
import art.arcane.iris.spi.IrisPlatforms;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
@@ -41,6 +42,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public final class ModdedStartup {
private static final int COMPAT_BOOT_KEY_CAP = 3;
private static final AtomicBoolean PREPARED = new AtomicBoolean(false);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
@@ -138,17 +140,23 @@ public final class ModdedStartup {
try {
PackValidationResult result = PackValidator.validate(packDir);
PackValidationRegistry.publish(result);
String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null;
String compatSummary = PackValidator.compatSummary(result, minecraftVersion);
String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary;
if (!result.isLoadable()) {
ModdedIrisLog.error("Iris pack '{}' FAILED validation with {} blocking error(s); world/studio creation will be refused. First error: {}",
result.getPackName(), result.getBlockingErrors().size(),
result.getBlockingErrors().getFirst());
} else if (!result.getWarnings().isEmpty()) {
ModdedIrisLog.info("Iris pack '{}' validated ({} warning(s)).", result.getPackName(), result.getWarnings().size());
ModdedIrisLog.info("Iris pack '{}' validated ({} warning(s)).{}", result.getPackName(), result.getWarnings().size(), compatSuffix);
for (String warning : result.getWarnings()) {
ModdedIrisLog.warn(" [{}] {}", result.getPackName(), warning);
}
} else {
ModdedIrisLog.info("Iris pack '{}' validated.", result.getPackName());
ModdedIrisLog.info("Iris pack '{}' validated.{}", result.getPackName(), compatSuffix);
}
for (String line : PackValidator.compatBootLines(result, minecraftVersion, COMPAT_BOOT_KEY_CAP)) {
ModdedIrisLog.warn("{}", line);
}
} catch (Throwable e) {
ModdedIrisLog.error("Iris pack validation failed for '{}'", packDir.getName(), e);
@@ -28,8 +28,9 @@ import java.util.Map;
public final class ModdedStateMerger implements BlockDataMergeSupport.StateMerger {
@Override
public PlatformBlockState merge(PlatformBlockState base, PlatformBlockState update) {
ModdedBlockState fabricBase = (ModdedBlockState) base;
ModdedBlockState fabricUpdate = (ModdedBlockState) update;
if (!(base instanceof ModdedBlockState fabricBase) || !(update instanceof ModdedBlockState fabricUpdate)) {
return update;
}
ModdedBlockState metadataSource = fabricUpdate.isCustom() ? fabricUpdate : fabricBase;
try {
@@ -47,7 +47,9 @@ public final class ModdedStateRotator implements IrisObjectRotation.StateRotator
@Override
public PlatformBlockState rotate(IrisObjectRotation rotation, PlatformBlockState state, int spinxx, int spinyy, int spinzz) {
ModdedBlockState fabric = (ModdedBlockState) state;
if (!(state instanceof ModdedBlockState fabric)) {
return state;
}
BlockState d = fabric.handle();
boolean deleted = false;
@@ -164,7 +164,8 @@ final class ModdedCommandHelp {
Entry.command("validate", "[pack]", ModdedHelpMessages.COMMAND_VALIDATE_VALIDATE_A_PACK_OR_EVERY_PACK, "v"),
Entry.command("cleanup", "<pack> [apply]", ModdedHelpMessages.COMMAND_CLEANUP_PREVIEW_OR_QUARANTINE_UNUSED_RESOURCE_CANDIDATES, "c"),
Entry.command("restore", "<pack> [apply]", ModdedHelpMessages.COMMAND_RESTORE_PREVIEW_OR_RESTORE_THE_LATEST_QUARANTINE, "r"),
Entry.command("status", "[pack]", ModdedHelpMessages.COMMAND_STATUS_SHOW_CACHED_VALIDATION_STATUS, "s")
Entry.command("status", "[pack]", ModdedHelpMessages.COMMAND_STATUS_SHOW_CACHED_VALIDATION_STATUS, "s"),
Entry.command("compat", "[pack]", ModdedHelpMessages.COMMAND_COMPAT_SHOW_CONTENT_THAT_CANNOT_GENERATE_ON_THIS_MINECRAFT_VERSION, "cp")
));
SECTIONS.put("pk", SECTIONS.get("pack"));
SECTIONS.put("world", List.of(
@@ -19,6 +19,7 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.core.compat.PackCompatReport;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackResourceCleanup;
@@ -93,6 +94,15 @@ public final class ModdedPackCommands {
.then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES)
.executes(ModdedCommandTree.localized((CommandContext<CommandSourceStack> context) -> status(context.getSource(), StringArgumentType.getString(context, "pack"))))));
root.then(Commands.literal("compat")
.executes(ModdedCommandTree.localized((CommandContext<CommandSourceStack> context) -> compat(context.getSource(), null)))
.then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES)
.executes(ModdedCommandTree.localized((CommandContext<CommandSourceStack> context) -> compat(context.getSource(), StringArgumentType.getString(context, "pack"))))));
root.then(Commands.literal("cp")
.executes(ModdedCommandTree.localized((CommandContext<CommandSourceStack> context) -> compat(context.getSource(), null)))
.then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES)
.executes(ModdedCommandTree.localized((CommandContext<CommandSourceStack> context) -> compat(context.getSource(), StringArgumentType.getString(context, "pack"))))));
return root;
}
@@ -215,6 +225,77 @@ public final class ModdedPackCommands {
return 1;
}
private static int compat(CommandSourceStack source, String pack) {
if (pack == null || pack.isBlank()) {
Map<String, PackValidationResult> snapshot = PackValidationRegistry.snapshot();
if (snapshot.isEmpty()) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_PACK_COMMANDS_NO_VALIDATION_RESULTS_RECORDED_RUN_IRIS_PACK_VALIDATE_FIRST));
return 0;
}
for (Map.Entry<String, PackValidationResult> entry : snapshot.entrySet()) {
for (String line : compatOutput(entry.getKey(), entry.getValue())) {
IrisModdedCommands.ok(source, line);
}
}
return 1;
}
PackValidationResult result = PackValidationRegistry.get(pack);
for (String line : compatOutput(pack, result)) {
if (result == null) {
IrisModdedCommands.fail(source, line);
} else {
IrisModdedCommands.ok(source, line);
}
}
return result == null ? 0 : 1;
}
/**
* The compat listing for one pack, straight from the persisted validation so no pack is reloaded. Every key is
* printed with every subject; the boot listing is the capped form of the same data.
*/
static List<String> compatOutput(String pack, PackValidationResult result) {
List<String> lines = new ArrayList<>();
if (result == null) {
lines.add(IrisLanguage.plain(
ModdedCommandMessages.MODDED_PACK_COMMANDS_NO_VALIDATION_RESULT_RUN_IRIS_PACK_VALIDATE,
MessageArgument.untrusted("pack", pack),
MessageArgument.untrusted("pack2", pack)));
return lines;
}
String version = compatVersion(result);
if (result.getCompatFindings().isEmpty()) {
lines.add(IrisLanguage.plain(
ModdedCommandMessages.MODDED_PACK_COMMANDS_COMPAT_NONE,
MessageArgument.untrusted("pack", pack),
MessageArgument.untrusted("version", version)));
return lines;
}
lines.add(IrisLanguage.plain(
ModdedCommandMessages.MODDED_PACK_COMMANDS_COMPAT_HEADER,
MessageArgument.untrusted("pack", pack),
MessageArgument.untrusted("version", version)));
for (String line : PackCompatReport.of(result.getCompatFindings()).keyLines(0)) {
lines.add(IrisLanguage.plain(
ModdedCommandMessages.MODDED_PACK_COMMANDS_MESSAGE_2,
MessageArgument.untrusted("value", line)));
}
lines.add(IrisLanguage.plain(ModdedCommandMessages.MODDED_PACK_COMMANDS_COMPAT_REMEDY));
return lines;
}
/** The live Minecraft version, falling back to the one the validation ran against. */
static String compatVersion(PackValidationResult result) {
if (IrisPlatforms.isBound()) {
String live = IrisPlatforms.get().minecraftVersion();
if (live != null && !live.isBlank()) {
return live;
}
}
String persisted = result.getMinecraftVersion();
return persisted == null || persisted.isBlank() ? "unknown" : persisted;
}
private static void report(CommandSourceStack source, PackValidationResult result) {
if (result.isLoadable()) {
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_PACK_COMMANDS_PACK_IS_LOADABLE_WARNINGS, MessageArgument.untrusted("value", result.getPackName()), MessageArgument.untrusted("value2", result.getWarnings().size())));
@@ -153,6 +153,8 @@ public class IrisModdedCommandParityTest {
assertTrue(ModdedCommandHelp.documents("object", "we"));
assertTrue(ModdedCommandHelp.documents("world", "mainworld"));
assertTrue(ModdedCommandHelp.documents("goto", "unregistered"));
assertTrue(ModdedCommandHelp.documents("pack", "compat"));
assertTrue(ModdedCommandHelp.documents("pack", "cp"));
}
@Test
@@ -170,6 +172,8 @@ public class IrisModdedCommandParityTest {
"structure", "struct", "str", "developer", "dev"}) {
assertNotNull(category, child(iris, category).getCommand());
}
assertNotNull("pack compat", child(child(iris, "pack"), "compat").getCommand());
assertNotNull("pack cp", child(child(iris, "pk"), "cp").getCommand());
}
@Test
+1
View File
@@ -94,6 +94,7 @@ art/arcane/iris/engine/WorldTeleportWarmup.java
art/arcane/iris/engine/data/chunk/LinkedTerrainChunk.java
art/arcane/iris/engine/data/chunk/TerrainChunk.java
art/arcane/iris/engine/decorator/DecoratorCore.java
art/arcane/iris/engine/decorator/IrisSpeleothems.java
art/arcane/iris/engine/framework/BukkitEngineWorldManager.java
art/arcane/iris/engine/framework/EngineAssignedWorldManager.java
art/arcane/iris/engine/framework/EnginePlayer.java
@@ -2,6 +2,7 @@ package art.arcane.iris.core;
import art.arcane.iris.BuildConstants;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisGeneratorBinding;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.core.nms.datapack.DataVersion;
@@ -235,6 +236,7 @@ public final class IrisDatapackCompiler {
fingerprintContent(dimension.getDimensionType().toJson(fixer)));
String namespace = dimension.getLoadKey().toLowerCase(Locale.ROOT);
ContentGate contentGate = dimension.getLoader() == null ? null : dimension.getLoader().getContentGate();
for (IrisBiome biome : dimension.getAllBiomes(dimension::getLoader)) {
if (biome == null || !biome.isCustom()) {
continue;
@@ -250,7 +252,7 @@ public final class IrisDatapackCompiler {
}
requirements.put(
"worldgen/biome/" + biomeKey,
fingerprintContent(customBiome.generateJson(fixer)));
fingerprintContent(customBiome.generateJson(fixer, contentGate)));
TreeSet<String> tags = new TreeSet<>();
for (String tag : customBiome.getEffectiveTags(derivativeKey)) {
String normalizedTag = normalizeRegistryKey(tag);
@@ -0,0 +1,40 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.compat;
import java.util.Locale;
/**
* What the gate did about a key that is missing on the running Minecraft version.
* <ul>
* <li>{@code EXCLUDED}: a compat unit (registrant or object placement) composes the missing content and was removed from
* every pool that could pick it.</li>
* <li>{@code DROPPED}: a single entry or reference was removed from its container; the container keeps generating.</li>
* <li>{@code SUBSTITUTED}: a declared fallback (dimension {@code blockFallbacks} or a block {@code backup}) replaced the
* missing key; the content still generates.</li>
* </ul>
*/
public enum CompatAction {
EXCLUDED,
DROPPED,
SUBSTITUTED;
public String label() {
return name().toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,84 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.compat;
import java.util.Objects;
/**
* One gating decision: {@code key} in {@code registry} is missing on this server, and the gate took {@code action} on
* {@code subjectType}/{@code subjectKey} (a registrant type name and load key, or an object path) at {@code detail}
* (a field path such as {@code layers[1].palette[2]}, or the fallback that was applied). Findings are deduplicated on
* every field except {@code detail}.
*/
public record CompatFinding(CompatRegistry registry,
String key,
CompatAction action,
String subjectType,
String subjectKey,
String detail) {
public CompatFinding {
Objects.requireNonNull(registry, "registry");
Objects.requireNonNull(key, "key");
Objects.requireNonNull(action, "action");
subjectType = subjectType == null ? "" : subjectType;
subjectKey = subjectKey == null ? "" : subjectKey;
detail = detail == null ? "" : detail;
}
/** Grouping key shared by every finding about the same registry entry. */
public String groupKey() {
return registry.name() + ':' + key;
}
/** Deduplication key: one finding per registry, key, action and subject. */
public String dedupKey() {
return groupKey() + '|' + action.name() + '|' + subjectType + '|' + subjectKey;
}
/** {@code <key> (<registry>): <action> <subjectType> <subjectKey> at <detail>} */
public String line() {
StringBuilder sb = new StringBuilder(96);
sb.append(key).append(" (").append(registry.label()).append("): ").append(action.label());
if (!subjectType.isEmpty()) {
sb.append(' ').append(subjectType);
}
if (!subjectKey.isEmpty()) {
sb.append(' ').append(subjectKey);
}
if (!detail.isEmpty()) {
sb.append(" at ").append(detail);
}
return sb.toString();
}
/** The part after the key, for lines already grouped under the key. */
public String subjectLine() {
StringBuilder sb = new StringBuilder(64);
sb.append(action.label());
if (!subjectType.isEmpty()) {
sb.append(' ').append(subjectType);
}
if (!subjectKey.isEmpty()) {
sb.append(' ').append(subjectKey);
}
if (!detail.isEmpty()) {
sb.append(" at ").append(detail);
}
return sb.toString();
}
}
@@ -0,0 +1,38 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.compat;
import java.util.Locale;
/**
* Registry a pack key is checked against by the {@link ContentGate}. Sounds and particles are absent on purpose: the
* platform SPI exposes no registry for them and an unknown effect already plays nothing.
*/
public enum CompatRegistry {
BLOCK,
ITEM,
ENTITY,
BIOME,
STRUCTURE,
ENCHANTMENT,
POTION_EFFECT;
public String label() {
return name().toLowerCase(Locale.ROOT).replace('_', ' ');
}
}
@@ -0,0 +1,36 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.compat;
import java.util.List;
/**
* Gate verdict for one compat unit. {@code excluded} units must be filtered out of every pool that could pick them;
* {@code reasons} lists every finding recorded while evaluating the unit (excluded or not).
*/
public record CompatStatus(boolean excluded, List<CompatFinding> reasons) {
public static final CompatStatus OK = new CompatStatus(false, List.of());
public CompatStatus {
reasons = reasons == null ? List.of() : List.copyOf(reasons);
}
public static CompatStatus excludedBy(List<CompatFinding> reasons) {
return new CompatStatus(true, reasons);
}
}
@@ -0,0 +1,525 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.compat;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.engine.object.IrisBlockData;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.engine.object.IrisStaticObject;
import art.arcane.iris.engine.object.annotations.RegistryListBiome;
import art.arcane.iris.engine.object.annotations.RegistryListBlockType;
import art.arcane.iris.engine.object.annotations.RegistryListEnchantment;
import art.arcane.iris.engine.object.annotations.RegistryListEntityType;
import art.arcane.iris.engine.object.annotations.RegistryListItemType;
import art.arcane.iris.engine.object.annotations.RegistryListPotionEffect;
import art.arcane.iris.engine.object.annotations.RegistryListResource;
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure;
import art.arcane.iris.engine.object.annotations.RegistryMapBlockState;
import art.arcane.iris.spi.IrisLogging;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* One evaluation of one registrant against the policy table: walks the registrant graph through a cached per-class
* field plan, checks every annotated key, resolves every {@link IrisBlockData} through the gate chain, drops entries
* the table says to drop, and records findings on the gate's report.
* <p>
* Boundaries: {@link IrisObjectPlacement} and {@link IrisStaticObject} are never descended (placements and static
* objects evaluate their own pools, so their edit palettes exclude only themselves), match-side block lists are
* ignored (a missing block that is only matched is harmless), registrant references ({@code @RegistryListResource})
* belong to the pool cascade, and identity cycles are cut.
*/
final class CompatWalker {
private static final String IRIS_PACKAGE = "art.arcane.iris.";
private static final String OBJECT_PACKAGE = "art.arcane.iris.engine.object.";
private static final int MAX_BACKUP_DEPTH = 8;
private static final Map<Class<?>, ClassPlan> PLANS = new ConcurrentHashMap<>();
// Annotated fields whose key defines the registrant itself: missing means the registrant is excluded.
private static final Set<String> EXCLUSION_SITES = Set.of(
OBJECT_PACKAGE + "IrisEntity#type",
OBJECT_PACKAGE + "IrisBiome#derivative");
// Block lists that only select which existing blocks to act on; a missing key there matches nothing and is harmless.
private static final Set<String> MATCH_ONLY = Set.of(
OBJECT_PACKAGE + "IrisObjectReplace#find",
OBJECT_PACKAGE + "IrisObjectLoot#filter",
OBJECT_PACKAGE + "IrisObjectVanillaLoot#filter",
OBJECT_PACKAGE + "IrisObjectMarker#mark",
OBJECT_PACKAGE + "IrisBlockDrops#blocks",
OBJECT_PACKAGE + "IrisDecorator#whitelist",
OBJECT_PACKAGE + "IrisDecorator#blacklist");
private final ContentGate gate;
private final IrisRegistrant registrant;
private final String subjectType;
private final String subjectKey;
private final boolean customLookup;
private final List<CompatFinding> reasons = new ArrayList<>();
private final Set<Object> visited = Collections.newSetFromMap(new IdentityHashMap<>());
private boolean excluded;
CompatWalker(ContentGate gate, IrisRegistrant registrant) {
this.gate = gate;
this.registrant = registrant;
// Lowercase so walker findings read like the pool-cascade findings ("excluded biome x", "dropped region y").
this.subjectType = registrant.getTypeName().toLowerCase(Locale.ROOT);
this.subjectKey = registrant.getLoadKey();
// A blocks/ registrant must not re-enter its own loader cache while it is being loaded.
this.customLookup = !(registrant instanceof IrisBlockData);
}
CompatStatus run() {
if (registrant instanceof IrisBlockData block) {
resolveEntry(block, "block");
} else {
walkObject(registrant, "");
}
if (excluded) {
return CompatStatus.excludedBy(reasons);
}
return reasons.isEmpty() ? CompatStatus.OK : new CompatStatus(false, reasons);
}
/** Walks one object; true when its owner must drop it (an annotated key on it is missing and it is not the registrant). */
private boolean walkObject(Object node, String path) {
if (!visited.add(node)) {
return false;
}
ClassPlan plan = plan(node.getClass());
boolean drop = false;
for (KeyField key : plan.keys()) {
Object value = read(key.field(), node);
if (value == null) {
continue;
}
String fieldPath = child(path, key.field().getName());
if (key.list()) {
if (value instanceof Collection<?> values) {
walkKeyList(key, values, fieldPath);
}
continue;
}
if (!(value instanceof String raw) || raw.isBlank()) {
continue;
}
if (key.registry() == CompatRegistry.BLOCK) {
resolveBlockKey(raw, fieldPath);
continue;
}
if (check(key.registry(), raw) != KeyStatus.MISSING) {
continue;
}
String missing = normalizedKey(raw);
if (node != registrant) {
dropped(key.registry(), missing, fieldPath);
drop = true;
} else if (key.exclusionSite()) {
exclude(key.registry(), missing, fieldPath);
} else {
dropped(key.registry(), missing, fieldPath);
write(key.field(), node, plan.defaultOf(key.field()));
}
}
for (Field field : plan.descents()) {
Object value = read(field, node);
if (value == null) {
continue;
}
String fieldPath = child(path, field.getName());
if (value instanceof IrisBlockData block) {
resolveEntry(block, fieldPath);
} else if (value instanceof Collection<?> values) {
walkCollection(values, fieldPath);
} else if (value instanceof Map<?, ?> map) {
walkMap(map, fieldPath);
} else if (isIrisObject(value.getClass()) && walkObject(value, fieldPath)) {
write(field, node, null);
}
}
return drop;
}
private void walkKeyList(KeyField key, Collection<?> values, String path) {
Iterator<?> iterator = values.iterator();
int index = 0;
while (iterator.hasNext()) {
Object element = iterator.next();
String elementPath = index(path, index++);
if (!(element instanceof String raw) || raw.isBlank()) {
continue;
}
if (key.registry() == CompatRegistry.BLOCK) {
resolveBlockKey(raw, elementPath);
continue;
}
if (check(key.registry(), raw) == KeyStatus.MISSING) {
dropped(key.registry(), normalizedKey(raw), elementPath);
iterator.remove();
}
}
}
private void walkCollection(Collection<?> values, String path) {
Iterator<?> iterator = values.iterator();
int index = 0;
while (iterator.hasNext()) {
Object element = iterator.next();
if (walkValue(element, index(path, index++))) {
iterator.remove();
}
}
}
private void walkMap(Map<?, ?> map, String path) {
Iterator<? extends Map.Entry<?, ?>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<?, ?> entry = iterator.next();
if (walkValue(entry.getValue(), path + '[' + entry.getKey() + ']')) {
iterator.remove();
}
}
}
private boolean walkValue(Object value, String path) {
if (value == null || selfGated(value.getClass())) {
return false;
}
if (value instanceof IrisBlockData block) {
resolveEntry(block, path);
return false;
}
if (value instanceof Collection<?> values) {
walkCollection(values, path);
return false;
}
if (value instanceof Map<?, ?> map) {
walkMap(map, path);
return false;
}
return isIrisObject(value.getClass()) && walkObject(value, path);
}
/** An annotated block key that generates (procedural trees, formations, ...): the chain without a backup. */
private void resolveBlockKey(String raw, String path) {
ContentGate.BlockResolution resolution = gate.resolveBlock(raw);
String key = normalizedKey(raw);
if (resolution == null) {
exclude(CompatRegistry.BLOCK, key, path);
} else if (resolution.source() == ContentGate.BlockResolution.Source.FALLBACK) {
substituted(key, path, "fallback " + resolution.resolvedKey());
}
}
/** A block entry the way {@code IrisBlockData.getBlockData} resolves it: custom block, chain, then backups. */
private void resolveEntry(IrisBlockData entry, String path) {
if (!visited.add(entry)) {
return;
}
String block = entry.getBlock();
if (block == null || block.isBlank()) {
return;
}
IrisBlockData custom = customBlock(block);
if (custom != null) {
if (custom.isCompatExcluded()) {
exclude(CompatRegistry.BLOCK, causeKey(custom, block), path);
}
return;
}
String state = entry.stateKey();
ContentGate.BlockResolution resolution = gate.resolveBlock(state);
String key = normalizedKey(state);
if (resolution != null) {
if (resolution.source() == ContentGate.BlockResolution.Source.FALLBACK) {
substituted(key, path, "fallback " + resolution.resolvedKey());
}
return;
}
String revived = resolveBackup(entry.getBackup(), 0);
if (revived != null) {
substituted(key, path, "backup " + revived);
return;
}
exclude(CompatRegistry.BLOCK, key, path);
}
/** The key a backup chain generates, or null when every link is missing. */
private String resolveBackup(IrisBlockData backup, int depth) {
if (backup == null || depth > MAX_BACKUP_DEPTH || !visited.add(backup)) {
return null;
}
String block = backup.getBlock();
if (block == null || block.isBlank()) {
return null;
}
IrisBlockData custom = customBlock(block);
if (custom != null) {
return custom.isCompatExcluded() ? null : ContentGate.normalizeState(block);
}
ContentGate.BlockResolution resolution = gate.resolveBlock(backup.stateKey());
if (resolution != null) {
return resolution.resolvedKey();
}
return resolveBackup(backup.getBackup(), depth + 1);
}
private IrisBlockData customBlock(String block) {
return customLookup ? gate.customBlock(block) : null;
}
private static String causeKey(IrisBlockData custom, String block) {
CompatStatus status = custom.getCompat();
if (status != null && !status.reasons().isEmpty()) {
return status.reasons().getFirst().key();
}
return normalizedKey(block);
}
private KeyStatus check(CompatRegistry registry, String raw) {
KeyStatus status = gate.status(registry, raw);
if (status == KeyStatus.UNKNOWN) {
gate.report().markIncomplete(registry.label() + " registry not ready");
}
return status;
}
private void exclude(CompatRegistry registry, String key, String detail) {
excluded = true;
record(new CompatFinding(registry, key, CompatAction.EXCLUDED, subjectType, subjectKey, detail));
}
private void dropped(CompatRegistry registry, String key, String detail) {
record(new CompatFinding(registry, key, CompatAction.DROPPED, subjectType, subjectKey, detail));
}
private void substituted(String key, String path, String how) {
record(new CompatFinding(CompatRegistry.BLOCK, key, CompatAction.SUBSTITUTED, subjectType, subjectKey, path + " (" + how + ")"));
}
private void record(CompatFinding finding) {
reasons.add(finding);
gate.report().record(finding);
}
private static String normalizedKey(String raw) {
return ContentGate.baseKey(ContentGate.normalizeState(raw));
}
private static String child(String path, String name) {
return path.isEmpty() ? name : path + '.' + name;
}
private static String index(String path, int index) {
return path + '[' + index + ']';
}
private static Object read(Field field, Object node) {
try {
return field.get(node);
} catch (IllegalAccessException | RuntimeException e) {
return null;
}
}
private static void write(Field field, Object node, Object value) {
try {
field.set(node, value);
} catch (IllegalAccessException | RuntimeException e) {
IrisLogging.debug("Compat gate could not reset " + field.getDeclaringClass().getSimpleName() + "." + field.getName() + ": " + e.getMessage());
}
}
static ClassPlan plan(Class<?> type) {
ClassPlan plan = PLANS.get(type);
if (plan == null) {
plan = ClassPlan.build(type);
PLANS.putIfAbsent(type, plan);
}
return plan;
}
static boolean isIrisObject(Class<?> type) {
return type.getName().startsWith(IRIS_PACKAGE)
&& !type.isEnum()
&& !type.isRecord()
&& !type.isAnnotation()
&& !selfGated(type);
}
/** Units that evaluate their own object pools and edit palettes; the walker stops at them. */
private static boolean selfGated(Class<?> type) {
return IrisObjectPlacement.class.isAssignableFrom(type) || IrisStaticObject.class.isAssignableFrom(type);
}
private static boolean descendable(Type type) {
if (type instanceof Class<?> clazz) {
return isIrisObject(clazz);
}
if (type instanceof ParameterizedType parameterized && parameterized.getRawType() instanceof Class<?> raw) {
Type[] arguments = parameterized.getActualTypeArguments();
if (Collection.class.isAssignableFrom(raw)) {
return arguments.length == 1 && descendable(arguments[0]);
}
if (Map.class.isAssignableFrom(raw)) {
return arguments.length == 2 && descendable(arguments[1]);
}
return isIrisObject(raw);
}
return false;
}
private static boolean isStringList(Field field) {
if (!Collection.class.isAssignableFrom(field.getType())) {
return false;
}
return field.getGenericType() instanceof ParameterizedType parameterized
&& parameterized.getActualTypeArguments().length == 1
&& parameterized.getActualTypeArguments()[0] == String.class;
}
/**
* The registry an annotated field is checked against, or null when the field carries no checkable key: registrant
* references, prefix-matching vanilla structure filters, Iris structure resources ({@code @RegistryListStructure}),
* and the structure-set and native jigsaw pool keys the platform SPI exposes no registry for.
*/
private static CompatRegistry registryOf(Field field) {
if (field.isAnnotationPresent(RegistryListBlockType.class)) {
return CompatRegistry.BLOCK;
}
if (field.isAnnotationPresent(RegistryListEntityType.class)) {
return CompatRegistry.ENTITY;
}
if (field.isAnnotationPresent(RegistryListItemType.class)) {
return CompatRegistry.ITEM;
}
if (field.isAnnotationPresent(RegistryListEnchantment.class)) {
return CompatRegistry.ENCHANTMENT;
}
if (field.isAnnotationPresent(RegistryListPotionEffect.class)) {
return CompatRegistry.POTION_EFFECT;
}
if (field.isAnnotationPresent(RegistryListBiome.class)) {
return CompatRegistry.BIOME;
}
RegistryListVanillaStructure vanilla = field.getAnnotation(RegistryListVanillaStructure.class);
if (vanilla != null && !vanilla.prefixes()) {
return CompatRegistry.STRUCTURE;
}
return null;
}
private static boolean open(Field field) {
try {
field.setAccessible(true);
return true;
} catch (RuntimeException e) {
return false;
}
}
record KeyField(Field field, CompatRegistry registry, boolean list, boolean exclusionSite) {
}
/** Per-class field plan: annotated key fields and the fields worth descending into. Immutable once built. */
static final class ClassPlan {
private final Class<?> type;
private final List<KeyField> keys;
private final List<Field> descents;
private volatile Object defaults;
private volatile boolean defaultsBuilt;
private ClassPlan(Class<?> type, List<KeyField> keys, List<Field> descents) {
this.type = type;
this.keys = keys;
this.descents = descents;
}
static ClassPlan build(Class<?> type) {
List<KeyField> keys = new ArrayList<>();
List<Field> descents = new ArrayList<>();
for (Class<?> current = type; current != null && current != Object.class; current = current.getSuperclass()) {
for (Field field : current.getDeclaredFields()) {
int modifiers = field.getModifiers();
if (Modifier.isStatic(modifiers) || Modifier.isTransient(modifiers) || field.isSynthetic()) {
continue;
}
if (field.isAnnotationPresent(RegistryListResource.class) || field.isAnnotationPresent(RegistryMapBlockState.class)) {
continue;
}
String site = current.getName() + '#' + field.getName();
if (MATCH_ONLY.contains(site)) {
continue;
}
CompatRegistry registry = registryOf(field);
if (registry != null) {
boolean list = isStringList(field);
if ((field.getType() == String.class || list) && open(field)) {
keys.add(new KeyField(field, registry, list, EXCLUSION_SITES.contains(site)));
}
continue;
}
if (descendable(field.getGenericType()) && open(field)) {
descents.add(field);
}
}
}
return new ClassPlan(type, List.copyOf(keys), List.copyOf(descents));
}
List<KeyField> keys() {
return keys;
}
List<Field> descents() {
return descents;
}
/** The field's value on a fresh instance (what a dropped top-level key resets to), or null. */
Object defaultOf(Field field) {
if (!defaultsBuilt) {
defaults = newInstance();
defaultsBuilt = true;
}
Object instance = defaults;
return instance == null ? null : read(field, instance);
}
private Object newInstance() {
try {
Constructor<?> constructor = type.getDeclaredConstructor();
constructor.setAccessible(true);
return constructor.newInstance();
} catch (Throwable e) {
return null;
}
}
}
}
@@ -0,0 +1,500 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.compat;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.engine.object.IrisBlockData;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.volmlib.util.io.IO;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Answers "does this pack key exist on the running server, and if not what do we generate instead" for one pack
* instance, and records every decision in the pack's {@link PackCompatReport}.
* <p>
* Existence is checked against the live {@link PlatformRegistries} only - never a version number - so the same pack
* gates identically on Bukkit and on the modded loaders for the same Minecraft version, and mods that add or remove
* content are covered for free. {@link KeyStatus#UNKNOWN} (registry not bound or not ready) never produces a finding.
* <p>
* Block resolution chain: strict registry lookup, legacy rename table (not reported), dimension {@code blockFallbacks}
* (reported), then {@code null} (missing). The per-entry {@code backup} of an {@code IrisBlockData} is applied by that
* class and by the walker around this chain. Resolutions are cached per normalized state.
*/
public final class ContentGate {
private static final String DEFAULT_NAMESPACE = "minecraft";
private static final String DIMENSIONS_FOLDER = "dimensions";
private static final String BLOCK_FALLBACKS_FIELD = "blockFallbacks";
private static final Object MISSING = new Object();
/** Legacy Bukkit potion effect enum names (lowercase) to the current key path; the Bukkit-typed
* {@code PotionEffectTypes} reads this table so it stays unloaded on the modded loaders. */
public static final Map<String, String> POTION_EFFECT_ALIASES = Map.of(
"slow", "slowness",
"fast_digging", "haste",
"slow_digging", "mining_fatigue",
"increase_damage", "strength",
"heal", "instant_health",
"harm", "instant_damage",
"jump", "jump_boost",
"confusion", "nausea",
"damage_resistance", "resistance");
private final PlatformRegistries registries;
private final Supplier<Map<String, String>> blockFallbacks;
private final PackCompatReport report;
private final Function<String, IrisBlockData> customBlocks;
private final Map<CompatRegistry, Set<String>> keySets = new ConcurrentHashMap<>();
private final Map<String, Object> resolutions = new ConcurrentHashMap<>();
private final Map<String, MissingBlockState> placeholders = new ConcurrentHashMap<>();
// Keys with no blocks/ registrant: the loader lists the folder on every miss, and every walked entry asks.
private final Set<String> customMisses = ConcurrentHashMap.newKeySet();
private volatile boolean readyKnown;
private volatile Map<String, String> fallbackCache;
public ContentGate(PlatformRegistries registries, Supplier<Map<String, String>> blockFallbacks, PackCompatReport report) {
this(registries, blockFallbacks, report, null);
}
public ContentGate(PlatformRegistries registries, Map<String, String> blockFallbacks, PackCompatReport report) {
this(registries, () -> blockFallbacks == null ? Map.of() : blockFallbacks, report, null);
}
/**
* @param customBlocks resolves a pack block key to its {@code blocks/} registrant, or null; consulted before the
* registry the way {@code IrisBlockData.getBlockData} does. May be null.
*/
public ContentGate(PlatformRegistries registries, Supplier<Map<String, String>> blockFallbacks, PackCompatReport report,
Function<String, IrisBlockData> customBlocks) {
this.registries = registries;
this.blockFallbacks = blockFallbacks == null ? Map::of : blockFallbacks;
this.report = report == null ? new PackCompatReport() : report;
this.customBlocks = customBlocks;
}
/** Gate for a pack folder: live platform registries, fallbacks read from the pack's dimension JSON. */
public static ContentGate forData(IrisData data) {
PlatformRegistries registries = IrisPlatforms.isBound() ? IrisPlatforms.get().registries() : null;
File folder = data == null ? null : data.getDataFolder();
PackCompatReport report = data == null ? new PackCompatReport() : data.getCompatReport();
Function<String, IrisBlockData> customBlocks = data == null ? null : key -> {
ResourceLoader<IrisBlockData> loader = data.getBlockLoader();
return loader == null ? null : loader.load(key, false);
};
return new ContentGate(registries, () -> readBlockFallbacks(folder), report, customBlocks);
}
public PackCompatReport report() {
return report;
}
/** True when the registry can be consulted; false means every status is {@link KeyStatus#UNKNOWN}. */
public boolean ready() {
if (readyKnown) {
return true;
}
if (registries == null) {
return false;
}
try {
List<String> blocks = registries.blockKeys();
boolean ready = blocks != null && !blocks.isEmpty();
if (ready) {
readyKnown = true;
}
return ready;
} catch (Throwable e) {
return false;
}
}
public KeyStatus block(String state) {
String normalized = normalizeState(state);
if (normalized == null) {
return KeyStatus.MISSING;
}
if (!ready()) {
return KeyStatus.UNKNOWN;
}
return lookup(normalized) != null ? KeyStatus.PRESENT : KeyStatus.MISSING;
}
public KeyStatus item(String key) {
return statusOf(CompatRegistry.ITEM, key);
}
public KeyStatus entity(String key) {
return statusOf(CompatRegistry.ENTITY, key);
}
public KeyStatus biome(String key) {
return statusOf(CompatRegistry.BIOME, key);
}
public KeyStatus structure(String key) {
return statusOf(CompatRegistry.STRUCTURE, key);
}
public KeyStatus enchantment(String key) {
return statusOf(CompatRegistry.ENCHANTMENT, key);
}
/** Potion effects match on the key path only and accept the legacy Bukkit enum names, as {@code IrisEffect} does. */
public KeyStatus potionEffect(String key) {
String normalized = normalizeState(key);
if (normalized == null) {
return KeyStatus.MISSING;
}
if (!ready()) {
return KeyStatus.UNKNOWN;
}
Set<String> paths = keySet(CompatRegistry.POTION_EFFECT);
if (paths == null) {
return KeyStatus.UNKNOWN;
}
String path = pathOf(baseKey(normalized));
return paths.contains(POTION_EFFECT_ALIASES.getOrDefault(path, path)) ? KeyStatus.PRESENT : KeyStatus.MISSING;
}
/** Status of {@code key} in {@code registry}; {@link CompatRegistry#BLOCK} goes through {@link #block(String)}. */
public KeyStatus status(CompatRegistry registry, String key) {
return switch (registry) {
case BLOCK -> block(key);
case ITEM -> item(key);
case ENTITY -> entity(key);
case BIOME -> biome(key);
case STRUCTURE -> structure(key);
case ENCHANTMENT -> enchantment(key);
case POTION_EFFECT -> potionEffect(key);
};
}
/**
* Resolved block for a pack state string, or {@code null} when it is missing on this server after the whole chain
* (or the registry is not ready). {@link BlockResolution#substituted()} is true only for a dimension fallback; a
* legacy rename is visible through {@link BlockResolution#source()} and is never reported.
*/
public BlockResolution resolveBlock(String state) {
String normalized = normalizeState(state);
if (normalized == null || !ready()) {
return null;
}
Object cached = resolutions.get(normalized);
if (cached == null) {
BlockResolution resolved = resolveUncached(normalized);
cached = resolved == null ? MISSING : resolved;
resolutions.putIfAbsent(normalized, cached);
}
return cached == MISSING ? null : (BlockResolution) cached;
}
/**
* {@link #resolveBlock(String)}, or a {@link MissingBlockState} carrying the key when the whole chain misses, so
* object palettes and {@code edit} find lists keep the key an edit rule can still rewrite. Null only when the
* registry is not ready (callers keep their plain lookup) or the state is blank.
*/
public PlatformBlockState resolveBlockOrPlaceholder(String state) {
String normalized = normalizeState(state);
if (normalized == null || !ready()) {
return null;
}
BlockResolution resolution = resolveBlock(normalized);
if (resolution != null) {
return resolution.state();
}
MissingBlockState placeholder = placeholders.get(normalized);
if (placeholder == null) {
PlatformBlockState air;
try {
air = registries.air();
} catch (Throwable e) {
air = null;
}
if (air == null) {
return null;
}
placeholder = MissingBlockState.of(normalized, air);
MissingBlockState raced = placeholders.putIfAbsent(normalized, placeholder);
if (raced != null) {
placeholder = raced;
}
}
return placeholder;
}
private BlockResolution resolveUncached(String normalized) {
PlatformBlockState direct = lookup(normalized);
if (direct != null) {
return new BlockResolution(direct, normalized, false, null, BlockResolution.Source.REGISTRY);
}
BlockResolution renamed = resolveRename(normalized);
if (renamed != null) {
return renamed;
}
String fallback = fallbacks().get(baseKey(normalized));
if (fallback != null) {
String normalizedFallback = normalizeState(fallback);
PlatformBlockState substitute = normalizedFallback == null ? null : lookup(normalizedFallback);
if (substitute != null) {
return new BlockResolution(substitute, normalizedFallback, true, normalized, BlockResolution.Source.FALLBACK);
}
}
return null;
}
private BlockResolution resolveRename(String normalized) {
String candidate = normalized;
for (int hop = 0; hop < LegacyBlockRenames.MAX_HOPS; hop++) {
String supplement = normalizeState(LegacyBlockRenames.supplementFor(candidate));
if (supplement == null) {
return null;
}
PlatformBlockState state = lookup(supplement);
if (state != null) {
return new BlockResolution(state, supplement, false, null, BlockResolution.Source.RENAME);
}
candidate = supplement;
}
return null;
}
/** The {@code blocks/} registrant for a pack block key, or null when there is none or no loader is attached. */
public IrisBlockData customBlock(String key) {
if (customBlocks == null || key == null || key.isBlank() || customMisses.contains(key)) {
return null;
}
try {
IrisBlockData custom = customBlocks.apply(key);
if (custom == null) {
customMisses.add(key);
}
return custom;
} catch (Throwable e) {
IrisLogging.reportError(e);
return null;
}
}
/** The dimension {@code blockFallbacks} map, base key to state, normalized. */
public Map<String, String> fallbacks() {
Map<String, String> cached = fallbackCache;
if (cached == null) {
Map<String, String> raw = blockFallbacks.get();
Map<String, String> normalized = new LinkedHashMap<>();
if (raw != null) {
for (Map.Entry<String, String> entry : raw.entrySet()) {
String key = normalizeState(entry.getKey());
if (key != null && entry.getValue() != null) {
normalized.put(baseKey(key), entry.getValue());
}
}
}
cached = Collections.unmodifiableMap(normalized);
fallbackCache = cached;
}
return cached;
}
/** Forget cached fallbacks, key sets, resolutions, placeholders and the readiness latch (studio hotload). */
public void invalidate() {
fallbackCache = null;
readyKnown = false;
keySets.clear();
resolutions.clear();
placeholders.clear();
customMisses.clear();
}
/**
* Evaluates a freshly loaded registrant against the policy table, records findings, drops entries the table says
* to drop, and returns the status the caller stores on the registrant. A registry that is not ready produces no
* findings and no mutation; the report is marked incomplete instead.
*/
public CompatStatus evaluate(IrisRegistrant registrant) {
if (registrant == null) {
return CompatStatus.OK;
}
if (!ready()) {
report.markIncomplete("registry not ready");
return CompatStatus.OK;
}
return new CompatWalker(this, registrant).run();
}
/** Reads {@code dimensions/*.json} {@code blockFallbacks} objects straight from disk, without the loader. */
public static Map<String, String> readBlockFallbacks(File packFolder) {
if (packFolder == null) {
return Map.of();
}
File[] dimensions = new File(packFolder, DIMENSIONS_FOLDER).listFiles((dir, name) -> name.endsWith(".json"));
if (dimensions == null || dimensions.length == 0) {
return Map.of();
}
Map<String, String> merged = new LinkedHashMap<>();
for (File file : dimensions) {
try {
JSONObject json = new JSONObject(IO.readAll(file));
if (!json.has(BLOCK_FALLBACKS_FIELD)) {
continue;
}
JSONObject fallbacks = json.getJSONObject(BLOCK_FALLBACKS_FIELD);
for (String key : fallbacks.keySet()) {
Object value = fallbacks.get(key);
if (value instanceof String state && !state.isBlank()) {
merged.putIfAbsent(key, state);
}
}
} catch (Throwable e) {
IrisLogging.warn("Could not read blockFallbacks from " + file.getName() + ": " + e.getMessage());
}
}
return merged.isEmpty() ? Map.of() : Collections.unmodifiableMap(merged);
}
/** {@code namespace:name[props]} lowercased with the default namespace applied; null for blank input. */
public static String normalizeState(String state) {
if (state == null) {
return null;
}
String trimmed = state.trim().toLowerCase(Locale.ROOT);
if (trimmed.isEmpty()) {
return null;
}
int props = trimmed.indexOf('[');
String base = props < 0 ? trimmed : trimmed.substring(0, props);
String rest = props < 0 ? "" : trimmed.substring(props);
if (base.indexOf(':') < 0) {
base = DEFAULT_NAMESPACE + ':' + base;
}
return base + rest;
}
/** Strips the {@code [props]} section. */
public static String baseKey(String normalizedState) {
if (normalizedState == null) {
return null;
}
int props = normalizedState.indexOf('[');
return props < 0 ? normalizedState : normalizedState.substring(0, props);
}
private static String pathOf(String baseKey) {
int namespace = baseKey.indexOf(':');
return namespace < 0 ? baseKey : baseKey.substring(namespace + 1);
}
private PlatformBlockState lookup(String normalized) {
try {
return registries.blockOrNull(normalized, false);
} catch (Throwable e) {
return null;
}
}
private KeyStatus statusOf(CompatRegistry registry, String key) {
String normalized = normalizeState(key);
if (normalized == null) {
return KeyStatus.MISSING;
}
if (!ready()) {
return KeyStatus.UNKNOWN;
}
Set<String> keys = keySet(registry);
if (keys == null) {
return KeyStatus.UNKNOWN;
}
return keys.contains(baseKey(normalized)) ? KeyStatus.PRESENT : KeyStatus.MISSING;
}
/** Normalized keys of a registry (paths only for potion effects), or null while that registry is empty. */
private Set<String> keySet(CompatRegistry registry) {
Set<String> cached = keySets.get(registry);
if (cached != null) {
return cached;
}
List<String> keys;
try {
keys = switch (registry) {
case BLOCK -> registries.blockKeys();
case ITEM -> registries.itemKeys();
case ENTITY -> registries.entityKeys();
case BIOME -> registries.biomeKeys();
case STRUCTURE -> registries.structureKeys();
case ENCHANTMENT -> registries.enchantmentKeys();
case POTION_EFFECT -> registries.potionEffectKeys();
};
} catch (Throwable e) {
return null;
}
if (keys == null || keys.isEmpty()) {
return null;
}
Set<String> built = new HashSet<>(keys.size() * 2);
for (String key : keys) {
String normalized = normalizeState(key);
if (normalized != null) {
String base = baseKey(normalized);
built.add(registry == CompatRegistry.POTION_EFFECT ? pathOf(base) : base);
}
}
Set<String> frozen = Collections.unmodifiableSet(built);
keySets.putIfAbsent(registry, frozen);
return frozen;
}
/**
* @param state the state to generate
* @param resolvedKey the normalized key that produced {@code state}
* @param substituted true when a dimension fallback produced {@code state} (reported)
* @param substitutedFrom the missing key the fallback replaced, or null
* @param source which step of the chain answered
*/
public record BlockResolution(PlatformBlockState state, String resolvedKey, boolean substituted, String substitutedFrom,
Source source) {
public enum Source {
REGISTRY,
RENAME,
FALLBACK
}
public BlockResolution {
Objects.requireNonNull(state, "state");
Objects.requireNonNull(source, "source");
}
public BlockResolution(PlatformBlockState state, String resolvedKey, boolean substituted, String substitutedFrom) {
this(state, resolvedKey, substituted, substitutedFrom, substituted ? Source.FALLBACK : Source.REGISTRY);
}
}
}
@@ -0,0 +1,28 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.compat;
/**
* Existence of a key in the live platform registry. {@code UNKNOWN} means the registry could not be consulted (not
* bound, or empty because the server is not ready yet); it must never be treated as {@code MISSING}.
*/
public enum KeyStatus {
PRESENT,
MISSING,
UNKNOWN
}
@@ -0,0 +1,238 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.compat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* The legacy block rename table: keys Minecraft renamed or removed, and the key to try instead. Pure data with no
* platform types so the {@link ContentGate} applies it identically on Bukkit and the modded loaders; the Bukkit
* {@code IrisCompat} filter list is built from the same entries.
* <p>
* A {@code when} without {@code exact} matches the bare block name (namespace and properties stripped, case
* insensitive); an exact entry matches the whole normalized state. Supplements can chain (the first entry whose
* supplement exists wins) and the table contains cycles, so callers bound the number of hops.
*/
public final class LegacyBlockRenames {
public static final int MAX_HOPS = 16;
public record Rename(String when, String supplement, boolean exact) {
public Rename(String when, String supplement) {
this(when, supplement, false);
}
}
private static final List<Rename> DEFAULTS = defaults0();
private static final Map<String, String> BY_NAME = byName(DEFAULTS);
private static final Map<String, String> BY_STATE = byState(DEFAULTS);
private LegacyBlockRenames() {
}
/** Every entry in table order. */
public static List<Rename> defaults() {
return DEFAULTS;
}
/**
* The supplement for a normalized state ({@code namespace:name[props]}, lowercase), or null when no entry applies.
* Exact entries are consulted first, then the bare name.
*/
public static String supplementFor(String normalizedState) {
if (normalizedState == null) {
return null;
}
String exact = BY_STATE.get(normalizedState);
if (exact != null) {
return exact;
}
String base = ContentGate.baseKey(normalizedState);
int namespace = base.indexOf(':');
return BY_NAME.get(namespace < 0 ? base : base.substring(namespace + 1));
}
private static Map<String, String> byName(List<Rename> renames) {
Map<String, String> map = new LinkedHashMap<>();
for (Rename rename : renames) {
if (!rename.exact()) {
map.putIfAbsent(rename.when().toLowerCase(Locale.ROOT), rename.supplement());
}
}
return Collections.unmodifiableMap(map);
}
private static Map<String, String> byState(List<Rename> renames) {
Map<String, String> map = new LinkedHashMap<>();
for (Rename rename : renames) {
if (rename.exact()) {
String state = ContentGate.normalizeState(rename.when());
if (state != null) {
map.putIfAbsent(state, rename.supplement());
}
}
}
return Collections.unmodifiableMap(map);
}
private static List<Rename> defaults0() {
List<Rename> filters = new ArrayList<>();
filters.add(new Rename("CHAIN", "IRON_CHAIN"));
filters.add(new Rename("GRASS", "SHORT_GRASS"));
filters.add(new Rename("SHORT_GRASS", "GRASS"));
// Below 1.16
filters.add(new Rename("WEEPING_VINES", "NETHER_FENCE"));
filters.add(new Rename("WEEPING_VINES_PLANT", "NETHER_FENCE"));
filters.add(new Rename("WARPED_WART_BLOCK", "NETHER_WART_BLOCK"));
filters.add(new Rename("TWISTING_VINES", "BAMBOO"));
filters.add(new Rename("TWISTING_VINES_PLANT", "BAMBOO"));
filters.add(new Rename("TARGET", "COBBLESTONE"));
filters.add(new Rename("SOUL_SOIL", "SOULSAND"));
filters.add(new Rename("SOUL_TORCH", "TORCH"));
filters.add(new Rename("SOUL_LANTERN", "LANTERN"));
filters.add(new Rename("SOUL_FIRE", "FIRE"));
filters.add(new Rename("SOUL_CAMPFIRE", "CAMPFIRE"));
filters.add(new Rename("SHROOMLIGHT", "GLOWSTONE"));
filters.add(new Rename("RESPAWN_ANCHOR", "OBSIDIAN"));
filters.add(new Rename("NETHER_SPROUTS", "RED_MUSHROOM"));
filters.add(new Rename("NETHER_GOLD_ORE", "GOLD_ORE"));
filters.add(new Rename("LODESTONE", "STONE"));
filters.add(new Rename("STRIPPED_WARPED_HYPHAE", "BROWN_MUSHROOM_BLOCK"));
filters.add(new Rename("STRIPPED_CRIMSON_HYPHAE", "RED_MUSHROOM_BLOCK"));
filters.add(new Rename("WARPED_HYPHAE", "MUSHROOM_STEM"));
filters.add(new Rename("CRIMSON_HYPHAE", "RED_MUSHROOM_BLOCK"));
filters.add(new Rename("GILDED_BLACKSTONE", "COBBLESTONE"));
filters.add(new Rename("CRYING_OBSIDIAN", "OBSIDIAN"));
filters.add(new Rename("STRIPPED_WARPED_STEM", "MUSHROOM_STEM"));
filters.add(new Rename("STRIPPED_CRIMSON_STEM", "MUSHROOM_STEM"));
filters.add(new Rename("WARPED_STEM", "MUSHROOM_STEM"));
filters.add(new Rename("CRIMSON_STEM", "MUSHROOM_STEM"));
filters.add(new Rename("CRIMSON_ROOTS", "RED_MUSHROOM"));
filters.add(new Rename("WARPED_ROOTS", "BROWN_MUSHROOM"));
filters.add(new Rename("CRIMSON_PLANKS", "OAK_PLANKS"));
filters.add(new Rename("WARPED_PLANKS", "OAK_PLANKS"));
filters.add(new Rename("WARPED_NYLIUM", "MYCELIUM"));
filters.add(new Rename("CRIMSON_NYLIUM", "MYCELIUM"));
filters.add(new Rename("WARPED_FUNGUS", "BROWN_MUSHROOM"));
filters.add(new Rename("CRIMSON_FUNGUS", "RED_MUSHROOM"));
filters.add(new Rename("CRACKED_NETHER_BRICKS", "NETHER_BRICKS"));
filters.add(new Rename("CHISELED_NETHER_BRICKS", "NETHER_BRICKS"));
filters.add(new Rename("NETHER_FENCE", "LEGACY_NETHER_FENCE"));
filters.add(new Rename("IRON_CHAIN", "IRON_BARS"));
filters.add(new Rename("NETHERITE_BLOCK", "QUARTZ_BLOCK"));
filters.add(new Rename("BLACKSTONE", "COBBLESTONE"));
filters.add(new Rename("BASALT", "STONE"));
filters.add(new Rename("ANCIENT_DEBRIS", "NETHERRACK"));
filters.add(new Rename("NETHERRACK", "LEGACY_NETHERRACK"));
// Below 1.15
filters.add(new Rename("HONEY_BLOCK", "OAK_LEAVES"));
filters.add(new Rename("BEEHIVE", "OAK_LEAVES"));
filters.add(new Rename("BEE_NEST", "OAK_LEAVES"));
// Below 1.14
filters.add(new Rename("GRANITE_WALL", "COBBLESTONE_WALL"));
filters.add(new Rename("BLUE_ICE", "PACKED_ICE"));
filters.add(new Rename("DIORITE_WALL", "COBBLESTONE_WALL"));
filters.add(new Rename("ANDESITE_WALL", "COBBLESTONE_WALL"));
filters.add(new Rename("SWEET_BERRY_BUSH", "GRASS"));
filters.add(new Rename("STONECUTTER", "CRAFTING_TABLE"));
filters.add(new Rename("SANDSTONE_STAIRS", "LEGACY_SANDSTONE_STAIRS"));
filters.add(new Rename("SMOOTH_SANDSTONE_STAIRS", "LEGACY_SANDSTONE_STAIRS"));
filters.add(new Rename("MOSSY_COBBLESTONE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new Rename("MOSSY_STONE_BRICK_STAIRS", "STONE_BRICK_STAIRS"));
filters.add(new Rename("POLISHED_GRANITE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new Rename("GRANITE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new Rename("POLISHED_DIORITE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new Rename("DIORITE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new Rename("POLISHED_ANDESITE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new Rename("ANDESITE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new Rename("STONE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new Rename("END_STONE_BRICK_STAIRS", "LEGACY_SANDSTONE_STAIRS"));
filters.add(new Rename("NETHER_BRICK_STAIRS", "LEGACY_NETHER_BRICK_STAIRS"));
filters.add(new Rename("RED_NETHER_BRICK_STAIRS", "NETHER_BRICK_STAIRS"));
filters.add(new Rename("SMOOTH_QUARTZ_STAIRS", "LEGACY_QUARTZ_STAIRS"));
filters.add(new Rename("QUARTZ_STAIRS", "LEGACY_QUARTZ_STAIRS"));
filters.add(new Rename("RED_SANDSTONE_STAIRS", "LEGACY_RED_SANDSTONE_STAIRS"));
filters.add(new Rename("SMOOTH_RED_SANDSTONE_STAIRS", "LEGACY_RED_SANDSTONE_STAIRS"));
filters.add(new Rename("STONE_SLAB", "SMOOTH_STONE_SLAB"));
filters.add(new Rename("SMOKER", "FURNACE"));
filters.add(new Rename("SMITHING_TABLE", "CRAFTING_TABLE"));
filters.add(new Rename("END_STONE_BRICK_SLAB", "SANDSTONE_SLAB"));
filters.add(new Rename("RED_NETHER_BRICK_SLAB", "NETHER_BRICK_SLAB"));
filters.add(new Rename("SMOOTH_QUARTZ_SLAB", "QUARTZ_SLAB"));
filters.add(new Rename("CUT_SANDSTONE_SLAB", "SANDSTONE_SLAB"));
filters.add(new Rename("CUT_RED_SANDSTONE_SLAB", "RED_SANDSTONE_SLAB"));
filters.add(new Rename("SMOOTH_RED_SANDSTONE_SLAB", "RED_SANDSTONE_SLAB"));
filters.add(new Rename("SMOOTH_SANDSTONE_SLAB", "SANDSTONE_SLAB"));
filters.add(new Rename("MOSSY_COBBLESTONE_SLAB", "COBBLESTONE_SLAB"));
filters.add(new Rename("MOSSY_STONE_BRICK_SLAB", "STONE_BRICK_SLAB"));
filters.add(new Rename("STONE_SLAB", "SMOOTH_STONE_SLAB"));
filters.add(new Rename("ANDESITE_SLAB", "COBBLESTONE_SLAB"));
filters.add(new Rename("ANDESITE_SLAB", "COBBLESTONE_SLAB"));
filters.add(new Rename("DIORITE_SLAB", "COBBLESTONE_SLAB"));
filters.add(new Rename("GRANITE_SLAB", "COBBLESTONE_SLAB"));
filters.add(new Rename("POLISHED_ANDESITE_SLAB", "SMOOTH_STONE_SLAB"));
filters.add(new Rename("POLISHED_DIORITE_SLAB", "SMOOTH_STONE_SLAB"));
filters.add(new Rename("POLISHED_GRANITE_SLAB", "SMOOTH_STONE_SLAB"));
filters.add(new Rename("WARPED_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new Rename("WARPED_SIGN", "LEGACY_SIGN_POST"));
filters.add(new Rename("SPRUCE_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new Rename("SPRUCE_SIGN", "LEGACY_SIGN_POST"));
filters.add(new Rename("OAK_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new Rename("OAK_SIGN", "LEGACY_SIGN_POST"));
filters.add(new Rename("JUNGLE_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new Rename("JUNGLE_SIGN", "LEGACY_SIGN_POST"));
filters.add(new Rename("DARK_OAK_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new Rename("DARK_OAK_SIGN", "LEGACY_SIGN_POST"));
filters.add(new Rename("CRIMSON_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new Rename("CRIMSON_SIGN", "LEGACY_SIGN_POST"));
filters.add(new Rename("BIRCH_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new Rename("BIRCH_SIGN", "LEGACY_SIGN_POST"));
filters.add(new Rename("ACACIA_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new Rename("ACACIA_SIGN", "LEGACY_SIGN_POST"));
filters.add(new Rename("SCAFFOLDING", "BIRCH_FENCE"));
filters.add(new Rename("LECTERN", "BOOKSHELF"));
filters.add(new Rename("LANTERN", "REDSTONE_LAMP"));
filters.add(new Rename("JIGSAW", "AIR"));
filters.add(new Rename("GRINDSTONE", "COBBLESTONE"));
filters.add(new Rename("FLETCHING_TABLE", "CRAFTING_TABLE"));
filters.add(new Rename("COMPOSTER", "CHEST"));
filters.add(new Rename("CARTOGRAPHY_TABLE", "CRAFTING_TABLE"));
filters.add(new Rename("CAMPFIRE", "DARK_OAK_SLAB"));
filters.add(new Rename("BLAST_FURNACE", "FURNACE"));
filters.add(new Rename("BELL", "REDSTONE_LAMP"));
filters.add(new Rename("minecraft:barrel[facing=south]", "minecraft:hay_bale[axis=z]", true));
filters.add(new Rename("minecraft:barrel[facing=north]", "minecraft:hay_bale[axis=z]", true));
filters.add(new Rename("minecraft:barrel[facing=east]", "minecraft:hay_bale[axis=x]", true));
filters.add(new Rename("minecraft:barrel[facing=west]", "minecraft:hay_bale[axis=x]", true));
filters.add(new Rename("minecraft:barrel[facing=up]", "minecraft:hay_bale[axis=y]", true));
filters.add(new Rename("minecraft:barrel[facing=down]", "minecraft:hay_bale[axis=y]", true));
filters.add(new Rename("BAMBOO", "BIRCH_FENCE"));
filters.add(new Rename("BAMBOO_SAPLING", "BIRCH_SAPLING"));
filters.add(new Rename("POTTED_BAMBOO", "POTTED_BIRCH_SAPLING"));
return Collections.unmodifiableList(filters);
}
}
@@ -0,0 +1,246 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.compat;
import art.arcane.iris.spi.PlatformBlockState;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* Placeholder for a block key the running server does not have. It keeps the missing key so an object placement's
* {@code edit} rule can still match and rewrite it (the type-replace rescue), and behaves as the platform's air
* everywhere else: every physical predicate and {@link #nativeHandle()} come from the real air state, so a placeholder
* that does reach the world places air and never throws.
*/
public final class MissingBlockState implements PlatformBlockState {
private final String key;
private final String materialKey;
private final String namespace;
private final Map<String, String> properties;
private final PlatformBlockState air;
private MissingBlockState(String key, PlatformBlockState air) {
this.key = key;
this.materialKey = ContentGate.baseKey(key);
int colon = materialKey.indexOf(':');
this.namespace = colon < 0 ? "minecraft" : materialKey.substring(0, colon);
this.properties = properties(key);
this.air = air;
}
/** @param normalizedKey the missing key as {@link ContentGate#normalizeState(String)} returns it */
public static MissingBlockState of(String normalizedKey, PlatformBlockState air) {
return new MissingBlockState(Objects.requireNonNull(normalizedKey, "key"), Objects.requireNonNull(air, "air"));
}
public static boolean isPlaceholder(PlatformBlockState state) {
return state instanceof MissingBlockState;
}
/** The missing key, without properties. */
public String missingKey() {
return materialKey;
}
@Override
public String key() {
return key;
}
@Override
public String namespace() {
return namespace;
}
@Override
public String materialKey() {
return materialKey;
}
@Override
public boolean isAir() {
return true;
}
@Override
public boolean isSolid() {
return false;
}
@Override
public boolean isOccluding() {
return false;
}
@Override
public boolean isCustom() {
return false;
}
@Override
public boolean isFluid() {
return false;
}
@Override
public boolean isWater() {
return false;
}
@Override
public boolean isWaterLogged() {
return false;
}
@Override
public boolean isLit() {
return false;
}
@Override
public boolean isUpdatable() {
return false;
}
@Override
public boolean isFoliage() {
return false;
}
@Override
public boolean isTreeBlock() {
return false;
}
@Override
public boolean isFoliagePlantable() {
return false;
}
@Override
public boolean isDecorant() {
return false;
}
@Override
public boolean isStorage() {
return false;
}
@Override
public boolean isStorageChest() {
return false;
}
@Override
public boolean isOre() {
return false;
}
@Override
public boolean isDeepSlate() {
return false;
}
@Override
public boolean isVineBlock() {
return false;
}
@Override
public boolean canPlaceOnto(PlatformBlockState onto) {
return air.canPlaceOnto(onto);
}
/**
* Same missing block, and every property this placeholder names is present with the same value on {@code state} -
* the partial-match contract the adapters use for exact {@code find} entries.
*/
@Override
public boolean matches(PlatformBlockState state) {
if (!(state instanceof MissingBlockState other) || !materialKey.equals(other.materialKey)) {
return false;
}
for (Map.Entry<String, String> required : properties.entrySet()) {
if (!required.getValue().equals(other.properties.get(required.getKey()))) {
return false;
}
}
return true;
}
@Override
public boolean hasTileEntity() {
return false;
}
@Override
public PlatformBlockState withProperty(String name, String value) {
Map<String, String> merged = new LinkedHashMap<>(properties);
merged.put(name, value);
StringBuilder rebuilt = new StringBuilder(materialKey).append('[');
boolean first = true;
for (Map.Entry<String, String> entry : merged.entrySet()) {
if (!first) {
rebuilt.append(',');
}
rebuilt.append(entry.getKey()).append('=').append(entry.getValue());
first = false;
}
return new MissingBlockState(rebuilt.append(']').toString(), air);
}
@Override
public Object nativeHandle() {
return air.nativeHandle();
}
@Override
public boolean equals(Object other) {
return this == other || (other instanceof MissingBlockState state && key.equals(state.key));
}
@Override
public int hashCode() {
return key.hashCode();
}
@Override
public String toString() {
return "missing:" + key;
}
private static Map<String, String> properties(String key) {
int open = key.indexOf('[');
if (open < 0) {
return Map.of();
}
int close = key.lastIndexOf(']');
String body = key.substring(open + 1, close < open ? key.length() : close);
Map<String, String> parsed = new LinkedHashMap<>();
for (String pair : body.split(",")) {
int eq = pair.indexOf('=');
if (eq > 0) {
parsed.put(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
}
}
return parsed;
}
}
@@ -0,0 +1,226 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.compat;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Every gating decision made for one pack instance ({@code IrisData}). Thread-safe; findings are deduplicated on
* {@link CompatFinding#dedupKey()} and kept in first-recorded order. Console rendering lives here so Bukkit and modded
* print the same text.
*/
public final class PackCompatReport {
private static final Comparator<CompatFinding> BY_ACTION = Comparator.comparingInt(finding -> finding.action().ordinal());
private final Map<String, CompatFinding> findings = new LinkedHashMap<>();
private String incompleteReason;
/** A report rebuilt from persisted findings, for rendering a boot listing or a command listing again. */
public static PackCompatReport of(Iterable<CompatFinding> findings) {
PackCompatReport report = new PackCompatReport();
report.recordAll(findings);
return report;
}
public synchronized void record(CompatFinding finding) {
if (finding == null) {
return;
}
findings.putIfAbsent(finding.dedupKey(), finding);
}
public synchronized void recordAll(Iterable<CompatFinding> all) {
if (all == null) {
return;
}
for (CompatFinding finding : all) {
record(finding);
}
}
/** Forget every finding and any incomplete marker; a studio hotload rebuilds the pack's report from scratch. */
public synchronized void clear() {
findings.clear();
incompleteReason = null;
}
/** The registry could not be consulted for part of the pack; the report may be missing findings. */
public synchronized void markIncomplete(String reason) {
if (incompleteReason == null) {
incompleteReason = reason == null ? "registry not ready" : reason;
}
}
public synchronized boolean isIncomplete() {
return incompleteReason != null;
}
public synchronized String incompleteReason() {
return incompleteReason;
}
public synchronized boolean isEmpty() {
return findings.isEmpty();
}
public synchronized int size() {
return findings.size();
}
public synchronized List<CompatFinding> findings() {
return List.copyOf(findings.values());
}
/**
* Findings grouped by {@link CompatFinding#groupKey()}, keys in first-seen order; inside a key the exclusions come
* first, then the drops, then the substitutions (first-seen within each action), so a capped listing shows the
* decisions that removed content before the ones that kept it.
*/
public synchronized Map<String, List<CompatFinding>> byKey() {
Map<String, List<CompatFinding>> grouped = new LinkedHashMap<>();
for (CompatFinding finding : findings.values()) {
grouped.computeIfAbsent(finding.groupKey(), ignored -> new ArrayList<>()).add(finding);
}
for (List<CompatFinding> group : grouped.values()) {
group.sort(BY_ACTION);
}
return grouped;
}
public synchronized Map<CompatAction, Integer> countsByAction() {
Map<CompatAction, Integer> counts = new EnumMap<>(CompatAction.class);
for (CompatFinding finding : findings.values()) {
counts.merge(finding.action(), 1, Integer::sum);
}
return counts;
}
public synchronized int distinctKeys() {
return byKey().size();
}
/**
* {@code 3 content keys unavailable on Minecraft 26.1.2: 2 excluded, 5 dropped, 1 substituted.} Empty string when
* there is nothing to say.
*/
public synchronized String summaryLine(String minecraftVersion) {
if (findings.isEmpty()) {
return "";
}
Map<CompatAction, Integer> counts = countsByAction();
int keys = distinctKeys();
StringBuilder sb = new StringBuilder(96);
sb.append(keys).append(keys == 1 ? " content key" : " content keys")
.append(" unavailable on Minecraft ").append(versionOrUnknown(minecraftVersion)).append(": ");
boolean first = true;
for (CompatAction action : CompatAction.values()) {
int count = counts.getOrDefault(action, 0);
if (count == 0) {
continue;
}
if (!first) {
sb.append(", ");
}
sb.append(count).append(' ').append(action.label());
first = false;
}
sb.append('.');
return sb.toString();
}
/**
* Boot listing: header, one line per key with up to {@code perKeyCap} subjects, then the remediation hint. Empty
* list when there are no findings.
*/
public synchronized List<String> bootLines(String packName, String minecraftVersion, int perKeyCap) {
List<String> lines = new ArrayList<>();
if (findings.isEmpty()) {
return lines;
}
lines.add("Pack '" + packName + "': content unavailable on Minecraft " + versionOrUnknown(minecraftVersion));
for (String line : keyLines(perKeyCap)) {
lines.add(" " + line);
}
lines.add(" Update the server to a newer Minecraft to restore this content, or declare fallbacks"
+ " (dimension blockFallbacks, block backup). /iris pack compat " + packName + " lists everything.");
return lines;
}
/**
* One line per key, with up to {@code perKeyCap} subjects each and a {@code +N more} tail beyond that; pass 0 for
* every subject. No header and no remediation hint - the command wraps these in localized text.
*/
public synchronized List<String> keyLines(int perKeyCap) {
List<String> lines = new ArrayList<>();
for (Map.Entry<String, List<CompatFinding>> entry : byKey().entrySet()) {
lines.add(renderKeyLine(entry.getValue(), perKeyCap));
}
if (incompleteReason != null) {
lines.add("(incomplete: " + incompleteReason + ")");
}
return lines;
}
/** One line per finding, grouped under a key header line. Empty list when there are no findings. */
public synchronized List<String> detailLines() {
List<String> lines = new ArrayList<>();
for (Map.Entry<String, List<CompatFinding>> entry : byKey().entrySet()) {
CompatFinding head = entry.getValue().getFirst();
lines.add(head.key() + " (" + head.registry().label() + ")");
for (CompatFinding finding : entry.getValue()) {
lines.add(" " + finding.subjectLine());
}
}
if (incompleteReason != null) {
lines.add("(incomplete: " + incompleteReason + ")");
}
return lines;
}
private static String renderKeyLine(List<CompatFinding> group, int perKeyCap) {
CompatFinding head = group.getFirst();
StringBuilder sb = new StringBuilder(128);
sb.append(head.key()).append(" (").append(head.registry().label()).append("): ");
int cap = perKeyCap <= 0 ? Integer.MAX_VALUE : perKeyCap;
int shown = 0;
for (CompatFinding finding : group) {
if (shown == cap) {
break;
}
if (shown > 0) {
sb.append("; ");
}
sb.append(finding.subjectLine());
shown++;
}
int rest = group.size() - shown;
if (rest > 0) {
sb.append("; +").append(rest).append(" more");
}
return sb.toString();
}
private static String versionOrUnknown(String minecraftVersion) {
return minecraftVersion == null || minecraftVersion.isBlank() ? "unknown" : minecraftVersion;
}
}
@@ -18,6 +18,9 @@
package art.arcane.iris.core.loader;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.compat.PackCompatReport;
import com.google.gson.ExclusionStrategy;
import com.google.gson.FieldAttributes;
import com.google.gson.Gson;
@@ -103,6 +106,8 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
private static final Set<IrisData> ENGINE_HOLDERS =
Collections.synchronizedSet(Collections.newSetFromMap(new IdentityHashMap<>()));
private final File dataFolder;
private final transient PackCompatReport compatReport = new PackCompatReport();
private transient volatile ContentGate contentGate;
private final int id;
private final boolean datapackCompiler;
private volatile boolean closed = false;
@@ -429,6 +434,35 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
}
public void preprocessObject(IrisRegistrant t) {
if (t == null) {
return;
}
try {
t.setCompat(t.evaluateCompat(getContentGate()));
} catch (Throwable e) {
IrisLogging.reportError(e);
t.setCompat(CompatStatus.OK);
}
}
/** Every version-content gating decision made for this pack instance. */
public PackCompatReport getCompatReport() {
return compatReport;
}
/** The version-content gate for this pack instance (lazy; rebuilt on hotload with the loaders). */
public ContentGate getContentGate() {
ContentGate gate = contentGate;
if (gate == null) {
synchronized (this) {
gate = contentGate;
if (gate == null) {
gate = ContentGate.forData(this);
contentGate = gate;
}
}
}
return gate;
}
public void close() {
@@ -481,6 +515,8 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
public synchronized void hotloaded() {
StructureGraphCatalog.invalidate(this);
IrisObjectScale.invalidate(this);
contentGate = null;
compatReport.clear();
closed = false;
possibleSnippets = new KMap<>();
builder = new GsonBuilder()
@@ -18,6 +18,8 @@
package art.arcane.iris.core.loader;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.compat.ContentGate;
import com.google.gson.GsonBuilder;
import art.arcane.iris.spi.IrisLogging;
import lombok.Data;
@@ -34,6 +36,23 @@ public abstract class IrisRegistrant {
private transient String loadKey;
private transient File loadFile;
/** Version-content gate verdict; excluded registrants must be filtered out of every pool that could pick them. */
@EqualsAndHashCode.Exclude
private transient CompatStatus compat = CompatStatus.OK;
/**
* Version-content gate hook, called once per load from {@code IrisData.preprocessObject} before the registrant is
* cached. The default runs the gate's annotated-field walker; subclasses whose exclusion also depends on their pools
* (spawners, loot tables, regions, the dimension, jigsaw pieces) override, call {@code super}, and combine.
*/
public CompatStatus evaluateCompat(ContentGate gate) {
return gate.evaluate(this);
}
public boolean isCompatExcluded() {
CompatStatus status = compat;
return status != null && status.excluded();
}
public abstract String getFolderName();
@@ -158,6 +158,19 @@ public final class BukkitRuntimeMessages {
"other", C.GRAY + " ... and {count} more warnings."
)
);
public static final TextKey COMMAND_PACK_COMPAT_HEADER = TextKey.of(
"iris.bukkit.runtime.commandpack.compat_header",
C.YELLOW + "Pack '" + "{pack}" + "': content unavailable on Minecraft " + "{version}"
);
public static final TextKey COMMAND_PACK_COMPAT_NONE = TextKey.of(
"iris.bukkit.runtime.commandpack.compat_none",
C.GREEN + "Pack '" + "{pack}" + "' uses no content that is unavailable on Minecraft " + "{version}" + "."
);
public static final TextKey COMMAND_PACK_COMPAT_REMEDY = TextKey.of(
"iris.bukkit.runtime.commandpack.compat_remedy",
C.GRAY + "Update the server to a newer Minecraft to restore this content, or declare fallbacks"
+ " (dimension blockFallbacks, block backup)."
);
public static final LinesKey COMMAND_DEVELOPER_UPDATE_WORLD_WARNING = LinesKey.of(
"iris.bukkit.runtime.commanddeveloper.update_world_warning",
C.RED + "You should always make a backup before using this",
@@ -644,6 +657,9 @@ public final class BukkitRuntimeMessages {
COMMAND_PACK_MESSAGE,
COMMAND_PACK_MESSAGE_2,
COMMAND_PACK_MORE_WARNING_S,
COMMAND_PACK_COMPAT_HEADER,
COMMAND_PACK_COMPAT_NONE,
COMMAND_PACK_COMPAT_REMEDY,
COMMAND_DEVELOPER_UPDATE_WORLD_WARNING,
BULK_STRUCTURE_IMPORTER_IMPORTING_VANILLA_DATAPACK_STRUCTURES_MODE_INCLUDENONJIGSAW,
BULK_STRUCTURE_IMPORTER_FAIL_INVALID_KEY,
@@ -572,6 +572,14 @@ public final class DirectorCommandMessages {
"iris.director.commandpack.param.pack_folder_name",
"The pack folder name"
);
public static final TextKey COMMAND_PACK_DIRECTOR_SHOW_CONTENT_CANNOT_GENERATE_THIS_MINECRAFT_VERSION = TextKey.of(
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version",
"Show pack content that cannot generate on this Minecraft version"
);
public static final TextKey COMMAND_PACK_PARAM_PACK_FOLDER_NAME_COMPAT = TextKey.of(
"iris.director.commandpack.param.pack_folder_name_compat",
"The pack folder name to report on"
);
public static final TextKey COMMAND_PREGEN_DIRECTOR_PREGENERATE_YOUR_IRIS_WORLDS = TextKey.of(
"iris.director.commandpregen.director.pregenerate_your_iris_worlds",
"Pregenerate your Iris worlds!"
@@ -1009,6 +1017,8 @@ public final class DirectorCommandMessages {
COMMAND_PACK_PARAM_PREVIEW_APPLY_2,
COMMAND_PACK_DIRECTOR_SHOW_CACHED_VALIDATION_STATUS_PACK,
COMMAND_PACK_PARAM_PACK_FOLDER_NAME,
COMMAND_PACK_DIRECTOR_SHOW_CONTENT_CANNOT_GENERATE_THIS_MINECRAFT_VERSION,
COMMAND_PACK_PARAM_PACK_FOLDER_NAME_COMPAT,
COMMAND_PREGEN_DIRECTOR_PREGENERATE_YOUR_IRIS_WORLDS,
COMMAND_PREGEN_DIRECTOR_PREGENERATE_WORLD,
COMMAND_PREGEN_PARAM_RADIUS_PREGEN_BLOCKS,
@@ -771,6 +771,19 @@ public final class ModdedCommandMessages {
"iris.modded.moddedpackcommands.message_2",
" ! " + "{value}"
);
public static final TextKey MODDED_PACK_COMMANDS_COMPAT_HEADER = TextKey.of(
"iris.modded.moddedpackcommands.compat_header",
"Pack '" + "{pack}" + "': content unavailable on Minecraft " + "{version}"
);
public static final TextKey MODDED_PACK_COMMANDS_COMPAT_NONE = TextKey.of(
"iris.modded.moddedpackcommands.compat_none",
"Pack '" + "{pack}" + "' uses no content that is unavailable on Minecraft " + "{version}" + "."
);
public static final TextKey MODDED_PACK_COMMANDS_COMPAT_REMEDY = TextKey.of(
"iris.modded.moddedpackcommands.compat_remedy",
"Update the server to a newer Minecraft to restore this content, or declare fallbacks"
+ " (dimension blockFallbacks, block backup)."
);
public static final TextKey MODDED_PACK_COMMANDS_MORE_WARNING_S = TextKey.of(
"iris.modded.moddedpackcommands.more_warning_s",
" ... and " + "{value}" + " more warning(s)."
@@ -1432,6 +1445,9 @@ public final class ModdedCommandMessages {
MODDED_PACK_COMMANDS_PACK_IS_BROKEN,
MODDED_PACK_COMMANDS_MESSAGE,
MODDED_PACK_COMMANDS_MESSAGE_2,
MODDED_PACK_COMMANDS_COMPAT_HEADER,
MODDED_PACK_COMMANDS_COMPAT_NONE,
MODDED_PACK_COMMANDS_COMPAT_REMEDY,
MODDED_PACK_COMMANDS_MORE_WARNING_S,
MODDED_PACK_COMMANDS_NO_CLEANUP_CANDIDATES_FOUND_PACK,
MODDED_PACK_COMMANDS_CLEANUP_PREVIEW_PACK_CANDIDATE_S_NO_FILES_WERE_CHANGED,
@@ -278,6 +278,10 @@ public final class ModdedHelpMessages {
"iris.modded.help.entry.command.status_validation",
"Show cached validation status"
);
public static final TextKey COMMAND_COMPAT_SHOW_CONTENT_THAT_CANNOT_GENERATE_ON_THIS_MINECRAFT_VERSION = TextKey.of(
"iris.modded.help.entry.command.compat_content",
"Show content that cannot generate on this Minecraft version"
);
public static final TextKey COMMAND_ENABLE_CREATE_AND_INJECT_A_PERSISTENT_IRIS_DIMENSION_AT_RUNTIME_DOWNLOADS_THE_PACK = TextKey.of(
"iris.modded.help.entry.command.enable",
"Create and inject a persistent Iris dimension at runtime; refuses if the pack is missing, quote pack:dimensionKey to pick a specific pack dimension"
@@ -484,6 +488,7 @@ public final class ModdedHelpMessages {
COMMAND_CLEANUP_PREVIEW_OR_QUARANTINE_UNUSED_RESOURCE_CANDIDATES,
COMMAND_RESTORE_PREVIEW_OR_RESTORE_THE_LATEST_QUARANTINE,
COMMAND_STATUS_SHOW_CACHED_VALIDATION_STATUS,
COMMAND_COMPAT_SHOW_CONTENT_THAT_CANNOT_GENERATE_ON_THIS_MINECRAFT_VERSION,
COMMAND_ENABLE_CREATE_AND_INJECT_A_PERSISTENT_IRIS_DIMENSION_AT_RUNTIME_DOWNLOADS_THE_PACK,
COMMAND_REPLACE_OVERWORLD_INJECT_AN_IRIS_PRIMARY_WORLD_AND_ROUTE_PLAYERS_THERE_INSTEAD_OF_THE,
COMMAND_DISABLE_EVACUATE_AND_UNLOAD_AN_IRIS_DIMENSION_WORLD_DATA_ON_DISK_IS_KEPT,
@@ -1,6 +1,9 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.ServerConfigurator;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatRegistry;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformRegistries;
@@ -28,7 +31,7 @@ import java.util.Optional;
import java.util.Set;
public final class PackValidationCache {
private static final int SCHEMA_VERSION = 2;
private static final int SCHEMA_VERSION = 3;
private static final long MAX_CACHE_BYTES = 16L * 1024L * 1024L;
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
@@ -105,11 +108,17 @@ public final class PackValidationCache {
|| !actual.add(cached.packName)) {
return Optional.empty();
}
List<CompatFinding> findings = readFindings(cached.compatFindings);
if (findings == null) {
return Optional.empty();
}
results.add(new PackValidationResult(
cached.packName,
cached.blockingErrors,
cached.warnings,
cached.validatedAtMillis));
cached.validatedAtMillis,
findings,
cached.minecraftVersion));
}
if (!expected.equals(actual)) {
return Optional.empty();
@@ -144,6 +153,8 @@ public final class PackValidationCache {
cached.blockingErrors = List.copyOf(result.getBlockingErrors());
cached.warnings = List.copyOf(result.getWarnings());
cached.validatedAtMillis = result.getValidatedAtMillis();
cached.minecraftVersion = result.getMinecraftVersion();
cached.compatFindings = writeFindings(result.getCompatFindings());
state.results.add(cached);
}
@@ -167,6 +178,48 @@ public final class PackValidationCache {
}
}
/** Null when a persisted finding is malformed; the caller discards the whole cache file. */
private static List<CompatFinding> readFindings(List<CachedFinding> cached) {
if (cached == null) {
return List.of();
}
List<CompatFinding> findings = new ArrayList<>(cached.size());
for (CachedFinding finding : cached) {
if (finding == null || finding.key == null || finding.registry == null || finding.action == null) {
return null;
}
CompatRegistry registry;
CompatAction action;
try {
registry = CompatRegistry.valueOf(finding.registry);
action = CompatAction.valueOf(finding.action);
} catch (IllegalArgumentException unknownEnum) {
return null;
}
findings.add(new CompatFinding(
registry, finding.key, action, finding.subjectType, finding.subjectKey, finding.detail));
}
return List.copyOf(findings);
}
private static List<CachedFinding> writeFindings(List<CompatFinding> findings) {
if (findings == null || findings.isEmpty()) {
return List.of();
}
List<CachedFinding> cached = new ArrayList<>(findings.size());
for (CompatFinding finding : findings) {
CachedFinding entry = new CachedFinding();
entry.registry = finding.registry().name();
entry.key = finding.key();
entry.action = finding.action().name();
entry.subjectType = finding.subjectType();
entry.subjectKey = finding.subjectKey();
entry.detail = finding.detail();
cached.add(entry);
}
return cached;
}
private static void updateSorted(MessageDigest digest, List<String> values) {
List<String> sorted = new ArrayList<>(Objects.requireNonNull(values, "Pack validation registry keys"));
sorted.sort(String::compareTo);
@@ -197,5 +250,16 @@ public final class PackValidationCache {
private List<String> blockingErrors;
private List<String> warnings;
private long validatedAtMillis;
private String minecraftVersion;
private List<CachedFinding> compatFindings;
}
private static final class CachedFinding {
private String registry;
private String key;
private String action;
private String subjectType;
private String subjectKey;
private String detail;
}
}
@@ -18,6 +18,8 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.compat.CompatFinding;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -27,15 +29,29 @@ public final class PackValidationResult {
private final List<String> blockingErrors;
private final List<String> warnings;
private final long validatedAtMillis;
private final List<CompatFinding> compatFindings;
private final String minecraftVersion;
/** Result without version-content gating data; used where the pack was never loaded through the gate. */
public PackValidationResult(String packName,
List<String> blockingErrors,
List<String> warnings,
long validatedAtMillis) {
this(packName, blockingErrors, warnings, validatedAtMillis, List.of(), null);
}
public PackValidationResult(String packName,
List<String> blockingErrors,
List<String> warnings,
long validatedAtMillis,
List<CompatFinding> compatFindings,
String minecraftVersion) {
this.packName = packName;
this.blockingErrors = blockingErrors == null ? new ArrayList<>() : new ArrayList<>(blockingErrors);
this.warnings = warnings == null ? new ArrayList<>() : new ArrayList<>(warnings);
this.validatedAtMillis = validatedAtMillis;
this.compatFindings = compatFindings == null ? List.of() : List.copyOf(compatFindings);
this.minecraftVersion = minecraftVersion;
}
public String getPackName() {
@@ -57,4 +73,14 @@ public final class PackValidationResult {
public long getValidatedAtMillis() {
return validatedAtMillis;
}
/** Every version-content gating decision recorded while this pack was validated. */
public List<CompatFinding> getCompatFindings() {
return compatFindings;
}
/** The raw {@code IrisPlatform.minecraftVersion()} the gate ran against, or null when it did not run. */
public String getMinecraftVersion() {
return minecraftVersion;
}
}
@@ -18,11 +18,31 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.PackCompatReport;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisImage;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.matter.IrisMatterObject;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
public final class PackValidator {
@@ -42,6 +62,14 @@ public final class PackValidator {
static final List<String> REMOVED_WORLDGEN_FIELDS = List.of("fluidBodies");
static final List<String> UNSUPPORTED_STRUCTURE_TRANSFORM_FIELDS = List.of("rotation", "translate", "scale");
static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+");
private static final int BLOCKING_REASON_LIMIT = 3;
/**
* Binary assets, not key-bearing registrants: an object is gated through the placements that can place it
* (palette header only) and images carry no registry keys, so loading every one of them here would parse the
* whole object library for nothing.
*/
private static final Set<Class<?>> UNGATED_LOADERS =
Set.of(IrisObject.class, IrisMatterObject.class, IrisImage.class);
private PackValidator() {
}
@@ -134,7 +162,190 @@ public final class PackValidator {
addDistinct(ContentKeyValidator.strictContent() ? blockingErrors : warnings, contentKeys.strict());
addDistinct(warnings, contentKeys.advisory());
return new PackValidationResult(packName, blockingErrors, warnings, validatedAt);
String minecraftVersion = validateLiveRegistries && IrisPlatforms.isBound()
? IrisPlatforms.get().minecraftVersion()
: null;
List<CompatFinding> compatFindings = validateLiveRegistries
? gateContent(packFolder, dimensionFiles, minecraftVersion, blockingErrors, warnings)
: List.of();
return new PackValidationResult(
packName, blockingErrors, warnings, validatedAt, compatFindings, minecraftVersion);
}
/**
* Forces a complete load of the pack through {@link IrisData} so every registrant and every reachable
* {@link IrisObjectPlacement} is evaluated by the version-content gate, then folds the resulting findings into
* this validation. Runs on a detached loader that is closed again, and only on a cache miss (the caller's cache
* fingerprint already covers the Minecraft version and the registry key sets).
*/
private static List<CompatFinding> gateContent(
File packFolder,
File[] dimensionFiles,
String minecraftVersion,
List<String> blockingErrors,
List<String> warnings
) {
if (!IrisPlatforms.isBound()) {
return List.of();
}
IrisData data = null;
try {
data = IrisData.openRuntime(packFolder);
if (!data.getContentGate().ready()) {
return List.of();
}
boolean dimensionExcluded = loadEverything(data, dimensionFiles);
List<CompatFinding> findings = data.getCompatReport().findings();
applyCompatFindings(minecraftVersion, dimensionExcluded, findings, blockingErrors, warnings);
return findings;
} catch (Throwable e) {
IrisLogging.reportError("Version-content gating failed for pack '" + packFolder.getName() + "'", e);
return List.of();
} finally {
if (data != null) {
try {
data.close();
} catch (Throwable ignored) {
}
}
}
}
/**
* Loads every registrant of every loader type (dimensions first, so dimension-level fallbacks are in place before
* anything else resolves) and evaluates every placement reachable from the dimensions, regions and biomes, plus
* every static object on each dimension.
*
* @return true when any dimension in the pack cannot generate on this server
*/
private static boolean loadEverything(IrisData data, File[] dimensionFiles) {
boolean dimensionExcluded = false;
for (File dimensionFile : dimensionFiles) {
IrisDimension dimension = data.getDimensionLoader().load(
PackValidationIo.stripExtension(dimensionFile.getName()), false);
if (dimension == null) {
continue;
}
if (dimension.isCompatExcluded()) {
dimensionExcluded = true;
}
// Static objects gate themselves (the walker never descends into them), so ask each one here.
dimension.evaluateStaticObjectsCompat(data);
}
Set<IrisObjectPlacement> placements = Collections.newSetFromMap(new IdentityHashMap<>());
for (Map.Entry<Class<? extends IrisRegistrant>, ResourceLoader<? extends IrisRegistrant>> entry
: data.getLoaders().entrySet()) {
if (UNGATED_LOADERS.contains(entry.getKey())) {
continue;
}
for (String key : entry.getValue().getPossibleKeys()) {
collectPlacements(entry.getValue().load(key, false), placements);
}
}
for (IrisObjectPlacement placement : placements) {
placement.evaluateCompat(data);
}
return dimensionExcluded;
}
private static void collectPlacements(IrisRegistrant registrant, Set<IrisObjectPlacement> out) {
if (registrant instanceof IrisBiome biome) {
addPlacements(biome.getObjects(), out);
return;
}
if (registrant instanceof IrisRegion region) {
addPlacements(region.getObjects(), out);
}
}
private static void addPlacements(Collection<IrisObjectPlacement> placements, Set<IrisObjectPlacement> out) {
if (placements == null) {
return;
}
for (IrisObjectPlacement placement : placements) {
if (placement != null) {
out.add(placement);
}
}
}
/**
* Turns gate findings into validation output: an excluded dimension is blocking, and any
* {@link ContentKeyValidator} warning about a key the gate already reported is dropped so the operator does not
* read the same missing key twice.
*/
static void applyCompatFindings(
String minecraftVersion,
boolean dimensionExcluded,
List<CompatFinding> findings,
List<String> blockingErrors,
List<String> warnings
) {
if (dimensionExcluded) {
addDistinct(blockingErrors, List.of(excludedDimensionError(minecraftVersion, findings)));
}
if (findings == null || findings.isEmpty() || warnings == null) {
return;
}
Set<String> reported = new LinkedHashSet<>();
for (CompatFinding finding : findings) {
reported.add("'" + finding.key() + "'");
}
warnings.removeIf(warning -> {
for (String quoted : reported) {
if (warning.contains(quoted)) {
return true;
}
}
return false;
});
}
/** {@code Pack cannot generate on Minecraft 26.1.2: minecraft:sulfur (block), minecraft:camel (entity)} */
static String excludedDimensionError(String minecraftVersion, List<CompatFinding> findings) {
StringBuilder sb = new StringBuilder(96);
sb.append("Pack cannot generate on Minecraft ")
.append(minecraftVersion == null || minecraftVersion.isBlank() ? "unknown" : minecraftVersion)
.append(": ");
Set<String> reasons = new LinkedHashSet<>();
if (findings != null) {
for (CompatFinding finding : findings) {
if (reasons.size() == BLOCKING_REASON_LIMIT) {
break;
}
reasons.add(finding.key() + " (" + finding.registry().label() + ")");
}
}
sb.append(reasons.isEmpty() ? "the dimension composes content this server does not have"
: String.join(", ", reasons));
return sb.toString();
}
/** Boot listing for a validated pack; empty when the gate found nothing. */
public static List<String> compatBootLines(PackValidationResult result, String minecraftVersion, int perKeyCap) {
if (result == null || result.getCompatFindings().isEmpty()) {
return List.of();
}
return PackCompatReport.of(result.getCompatFindings())
.bootLines(result.getPackName(), versionOf(result, minecraftVersion), perKeyCap);
}
/** One-line compat summary appended to the {@code Pack 'x' validated (...)} line; empty when nothing to say. */
public static String compatSummary(PackValidationResult result, String minecraftVersion) {
if (result == null || result.getCompatFindings().isEmpty()) {
return "";
}
return PackCompatReport.of(result.getCompatFindings())
.summaryLine(versionOf(result, minecraftVersion));
}
private static String versionOf(PackValidationResult result, String minecraftVersion) {
if (minecraftVersion != null && !minecraftVersion.isBlank()) {
return minecraftVersion;
}
return result.getMinecraftVersion();
}
private static void addDistinct(List<String> destination, List<String> additions) {
@@ -142,8 +142,19 @@ public class TreeSVC implements IrisService {
return;
}
KList<String> pool = placement.compatPlace(worldAccess.getData());
if (pool.isEmpty()) {
IrisLogging.debug("Tree placement has no object available on this Minecraft version");
return;
}
saplingPlane.forEach(block -> block.setType(Material.AIR));
IrisObject object = worldAccess.getData().getObjectLoader().load(placement.getPlace().getRandom(RNG.r));
IrisObject object = worldAccess.getData().getObjectLoader().load(pool.getRandom(RNG.r));
if (object == null) {
return;
}
String treeMarker = object.getLoadKey() + "@" + ThreadLocalRandom.current().nextInt(Integer.MAX_VALUE);
List<BlockState> blockStateList = new KList<>();
KMap<Location, BlockData> dataCache = new KMap<>();
@@ -311,12 +322,12 @@ public class TreeSVC implements IrisService {
// Retrieve objectPlacements of type `species` from biome
IrisBiome biome = worldAccess.getEngine().getBiome(location.getBlockX(), location.getBlockY()-worldAccess.getTarget().getWorld().minHeight(), location.getBlockZ());
placements.addAll(matchObjectPlacements(biome.getObjects(), size, type));
placements.addAll(matchObjectPlacements(biome.getObjects(), size, type, worldAccess.getData()));
// Add more or find any in the region
if (isUseAll || placements.isEmpty()) {
IrisRegion region = worldAccess.getEngine().getRegion(location.getBlockX(), location.getBlockZ());
placements.addAll(matchObjectPlacements(region.getObjects(), size, type));
placements.addAll(matchObjectPlacements(region.getObjects(), size, type, worldAccess.getData()));
}
// Check if no matches were found, return a random one if they are
@@ -329,14 +340,15 @@ public class TreeSVC implements IrisService {
* @param objects The object placements to check
* @param size The size of the sapling area to filter with
* @param type The type of the tree to filter with
* @param data The pack, for the version-content gate
* @return A list of objectPlacements that matched. May be empty.
*/
private KList<IrisObjectPlacement> matchObjectPlacements(KList<IrisObjectPlacement> objects, IrisTreeSize size, TreeType type) {
private KList<IrisObjectPlacement> matchObjectPlacements(KList<IrisObjectPlacement> objects, IrisTreeSize size, TreeType type, IrisData data) {
KList<IrisObjectPlacement> p = new KList<>();
for (IrisObjectPlacement i : objects) {
if (i.matches(size, type)) {
if (i.matches(size, type) && !i.isCompatExcluded(data)) {
p.add(i);
}
}
@@ -20,6 +20,7 @@ package art.arcane.iris.engine;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.engine.data.cache.Cache;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
@@ -178,8 +179,9 @@ public class IrisComplex implements DataProvider {
fluidHeight = engine.getDimension().getFluidHeight();
generators = new HashMap<>();
generatorBiomes = Collections.newSetFromMap(new IdentityHashMap<>());
focusBiome = engine.getFocus();
focusRegion = engine.getFocusRegion();
// A registrant the version-content gate excluded never enters a pool, focus included.
focusBiome = compatUsable(engine.getFocus());
focusRegion = compatUsable(engine.getFocusRegion());
Map<InferredType, ProceduralStream<IrisBiome>> inferredStreams = new HashMap<>();
KList<IrisRegion> preparedRegions = new KList<>();
@@ -195,7 +197,7 @@ public class IrisComplex implements DataProvider {
} else {
engine.getDimension().getRegions().forEach(regionKey -> {
IrisRegion region = data.getRegionLoader().load(regionKey);
if (region == null) {
if (region == null || region.isCompatExcluded()) {
return;
}
prepareInferredBiomes(region, preparedRegions);
@@ -244,7 +246,7 @@ public class IrisComplex implements DataProvider {
ProceduralStream.of((x, z) -> focusRegion,
Interpolated.of(a -> 0D, a -> focusRegion))
: regionStyleStream
.selectRarity(data.getRegionLoader().loadAll(engine.getDimension().getRegions()))
.selectRarity(compatRegionPool(engine))
.cache2D("regionStream", engine, cacheSize);
regionStream = focusRegion != null ? proceduralRegionStream : proceduralRegionStream
.convertAware2D((region, x, z) -> {
@@ -1102,11 +1104,30 @@ public class IrisComplex implements DataProvider {
private KList<IrisBiome> loadInferredBiomes(KList<String> keys, InferredType type) {
KList<IrisBiome> inferred = new KList<>();
for (IrisBiome biome : data.getBiomeLoader().loadAll(keys)) {
// Excluded biomes cannot generate on this Minecraft version, so they leave the selection pool.
if (biome.isCompatExcluded()) {
continue;
}
inferred.add(biome.withInferredType(type));
}
return inferred;
}
/** The dimension's region pool with regions the version-content gate excluded removed. */
private KList<IrisRegion> compatRegionPool(Engine engine) {
KList<IrisRegion> pool = new KList<>();
for (IrisRegion region : data.getRegionLoader().loadAll(engine.getDimension().getRegions())) {
if (!region.isCompatExcluded()) {
pool.add(region);
}
}
return pool;
}
private static <T extends IrisRegistrant> T compatUsable(T registrant) {
return registrant == null || registrant.isCompatExcluded() ? null : registrant;
}
private void registerGenerators(IrisBiome biome) {
generatorBiomes.add(biome);
biome.getGenerators().forEach(c -> registerGenerator(c.getCachedGenerator(this)));
@@ -39,6 +39,9 @@ import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisEngineData;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.core.compat.PackCompatReport;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.spi.IrisLogging;
import com.google.common.util.concurrent.AtomicDouble;
import art.arcane.iris.spi.IrisPlatforms;
@@ -66,6 +69,7 @@ import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import java.io.File;
import java.util.HashSet;
import java.util.Locale;
import java.util.Objects;
@@ -234,6 +238,7 @@ public class IrisEngine implements Engine {
EngineRuntime initialRuntime = runtimeBuilder.buildRuntime();
runtimeBuilder.publishRuntime(initialRuntime, null);
IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms");
logPackCompatSummary();
logStudioInitializationPhase("build_runtime", phaseStartedAt, false);
phaseStartedAt = System.nanoTime();
if (requiredMode.warmGenerationCaches()) {
@@ -254,6 +259,38 @@ public class IrisEngine implements Engine {
IrisLogging.debug("Engine Initialized " + getCacheID());
}
/**
* One line per engine naming what this pack cannot generate on the running Minecraft version. The published
* validation result is complete (validation force-loads the whole pack), so it is preferred; an unvalidated pack
* falls back to what this engine has gated while building its runtime (dimension, regions, biomes). Never throws:
* a report failure must not take an otherwise working world down with it.
*/
private void logPackCompatSummary() {
try {
File folder = getData().getDataFolder();
// A world engine reads its snapshot copy under <world>/iris/pack, so the dimension key is the pack's name.
String pack = getDimension().getLoadKey();
String world = target.getWorld().name();
String version = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null;
PackValidationResult published = PackValidationRegistry.get(folder.toPath());
if (published == null) {
published = PackValidationRegistry.get(folder.getName());
}
PackCompatReport report = published != null && !published.getCompatFindings().isEmpty()
? PackCompatReport.of(published.getCompatFindings())
: getData().getCompatReport();
if (!report.isEmpty()) {
IrisLogging.info("World '" + world + "' pack '" + pack + "' " + report.summaryLine(version));
}
if (getDimension().isCompatExcluded()) {
IrisLogging.error("World '" + world + "' pack '" + pack + "' cannot generate on Minecraft "
+ (version == null || version.isBlank() ? "unknown" : version));
}
} catch (Throwable e) {
IrisLogging.debug("Pack compat summary failed: " + e.getMessage());
}
}
public void awaitGenerationCacheWarm() {
if (generationCacheWarm.isDone() && !generationCacheWarm.isCompletedExceptionally()) {
return;
@@ -682,7 +719,8 @@ public class IrisEngine implements Engine {
return null;
}
return getData().getBiomeLoader().load(getDimension().getFocus());
IrisBiome focus = getData().getBiomeLoader().load(getDimension().getFocus());
return focus == null || focus.isCompatExcluded() ? null : focus;
}
@Override
@@ -691,7 +729,8 @@ public class IrisEngine implements Engine {
return null;
}
return getData().getRegionLoader().load(getDimension().getFocusRegion());
IrisRegion focus = getData().getRegionLoader().load(getDimension().getFocusRegion());
return focus == null || focus.isCompatExcluded() ? null : focus;
}
@Override
@@ -83,6 +83,9 @@ final class MarkerSpawnScanner {
IrisLogging.error("Cannot load spawner: " + i + " for marker on " + manager.getName());
continue;
}
if (m.isCompatExcluded()) {
continue;
}
m.setReferenceMarker(mark);
// This is so fucking incorrect its a joke
@@ -182,6 +185,9 @@ final class MarkerSpawnScanner {
IrisLogging.error("Cannot load spawner: " + i + " for marker on " + manager.getName());
continue;
}
if (spawner.isCompatExcluded()) {
continue;
}
spawner.setReferenceMarker(mark);
data.spawners.add(spawner);
}
@@ -299,7 +299,8 @@ final class WorldEntitySpawner {
}
//@builder
Predicate<IrisSpawner> filter = i -> i.canSpawn(manager.getEngine(), c.getX(), c.getZ());
// Excluded spawners cannot spawn anything on this Minecraft version, so they never enter the selection pool.
Predicate<IrisSpawner> filter = i -> !i.isCompatExcluded() && i.canSpawn(manager.getEngine(), c.getX(), c.getZ());
ChunkCounter counter = new ChunkCounter(c.getEntities());
IrisBiome biome = EngineBukkitOps.getSurfaceBiome(manager.getEngine(), c);
@@ -518,7 +518,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
IrisRegion region = getRegion(x, z);
for (IrisObjectPlacement i : region.getObjects()) {
if (i.getPlace().contains(object)) {
if (i.compatPlace(getData()).contains(object)) {
return new PlacedObject(i, getData().getObjectLoader().load(object), id, x, z);
}
}
@@ -526,7 +526,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
IrisBiome biome = getSurfaceBiome(x, z);
for (IrisObjectPlacement i : biome.getObjects()) {
if (i.getPlace().contains(object)) {
if (i.compatPlace(getData()).contains(object)) {
return new PlacedObject(i, getData().getObjectLoader().load(object), id, x, z);
}
}
@@ -542,19 +542,20 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
return false;
}
IrisData data = getData();
Set<String> biomeKeys = getDimension().getReachableBiomes(this).stream()
.filter((i) -> containsObjectPlacement(i.getObjects(), normalizedObjectKey))
.filter((i) -> containsObjectPlacement(i.getObjects(), normalizedObjectKey, data))
.map(IrisRegistrant::getLoadKey)
.collect(Collectors.toSet());
Set<String> regionKeys = getDimension().getAllRegions(this).stream()
.filter((i) -> i.getAllBiomeIds().stream().anyMatch(biomeKeys::contains)
|| containsObjectPlacement(i.getObjects(), normalizedObjectKey))
|| containsObjectPlacement(i.getObjects(), normalizedObjectKey, data))
.map(IrisRegistrant::getLoadKey)
.collect(Collectors.toSet());
return !regionKeys.isEmpty();
}
private static boolean containsObjectPlacement(KList<IrisObjectPlacement> placements, String normalizedObjectKey) {
private static boolean containsObjectPlacement(KList<IrisObjectPlacement> placements, String normalizedObjectKey, IrisData data) {
if (placements == null || placements.isEmpty() || normalizedObjectKey.isBlank()) {
return false;
}
@@ -564,7 +565,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
continue;
}
for (String placedObject : placement.getPlace()) {
for (String placedObject : placement.compatPlace(data)) {
String normalizedPlacedObject = normalizeObjectPlacementKey(placedObject);
if (!normalizedPlacedObject.isBlank() && normalizedPlacedObject.equals(normalizedObjectKey)) {
return true;
@@ -20,6 +20,7 @@ package art.arcane.iris.engine.framework;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.engine.object.IrisRegion;
@@ -410,7 +411,7 @@ public final class HintedLocator<T> implements Locator<T> {
continue;
}
if (placesObject(region.getSurfaceObjects(), objectKey)) {
if (placesObject(region.getSurfaceObjects(), objectKey, engine.getData())) {
directRegions.add(region.getLoadKey());
}
@@ -511,9 +512,9 @@ public final class HintedLocator<T> implements Locator<T> {
};
}
private static boolean placesObject(KList<IrisObjectPlacement> placements, String objectKey) {
private static boolean placesObject(KList<IrisObjectPlacement> placements, String objectKey, IrisData data) {
for (IrisObjectPlacement placement : placements) {
if (placement != null && placement.getPlace().contains(objectKey)) {
if (placement != null && placement.compatPlace(data).contains(objectKey)) {
return true;
}
}
@@ -637,7 +638,7 @@ public final class HintedLocator<T> implements Locator<T> {
continue;
}
if (placesObject(biome.getSurfaceObjects(), objectKey)) {
if (placesObject(biome.getSurfaceObjects(), objectKey, engine.getData())) {
return true;
}
@@ -507,6 +507,10 @@ public final class IrisStructureLocator {
throw new IllegalStateException("Iris structure placement references missing structure '"
+ selectedKey + "'");
}
if (structure.isCompatExcluded()) {
// Gated out on this Minecraft version; the pack report already says why.
return null;
}
StructureGraphCompilation compilation = StructureGraphCatalog.compile(engine.getData(), structure);
if (!compilation.isAssemblyViable()) {
throw new IllegalStateException("Iris structure '" + selectedKey
@@ -1179,6 +1179,11 @@ public final class StructureAssembler {
if (pool.getPieces() == null) {
throw assemblyFailure("pool has no piece list");
}
// A pool the version-content gate excluded has nothing left to place: treat it as empty so the branch
// terminates or falls back, instead of failing the whole assembly.
if (pool.isCompatExcluded()) {
return List.of();
}
List<IrisJigsawPieceEntry> eligible = new ArrayList<>();
List<IrisJigsawPieceEntry> required = new ArrayList<>();
for (IrisJigsawPieceEntry entry : pool.getPieces()) {
@@ -1199,6 +1204,9 @@ public final class StructureAssembler {
if (piece == null) {
throw assemblyFailure("pool references missing piece '" + pieceKey + "'");
}
if (piece.isCompatExcluded()) {
continue;
}
int placements = placementCounts.getOrDefault(pieceKey, 0);
if (!pieceEnabled(piece)
|| terminalOnly && !piece.resolvedRules().isTerminal()
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology.policy;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisRegion;
@@ -17,7 +19,8 @@ public final class RiverPolicyResolver {
return resolve(
dimension == null ? null : dimension.getRiverPolicy(),
region == null ? null : region.getRiverPolicy(),
biome == null ? null : biome.getRiverPolicy()
biome == null ? null : biome.getRiverPolicy(),
loaderOf(biome, region, dimension)
);
}
@@ -25,14 +28,37 @@ public final class RiverPolicyResolver {
IrisRiverPolicy dimensionPolicy,
IrisRiverPolicy regionPolicy,
IrisRiverPolicy biomePolicy
) {
return resolve(dimensionPolicy, regionPolicy, biomePolicy, null);
}
/**
* @param data pack the policies came from, used to drop river biome references the version-content gate excluded.
* Null skips that filtering.
*/
public static EffectiveRiverPolicy resolve(
IrisRiverPolicy dimensionPolicy,
IrisRiverPolicy regionPolicy,
IrisRiverPolicy biomePolicy,
IrisData data
) {
State state = new State();
state.apply(dimensionPolicy, RiverConfinement.REGION);
state.apply(regionPolicy, RiverConfinement.REGION);
state.apply(biomePolicy, RiverConfinement.BIOME);
state.apply(dimensionPolicy, RiverConfinement.REGION, data);
state.apply(regionPolicy, RiverConfinement.REGION, data);
state.apply(biomePolicy, RiverConfinement.BIOME, data);
return state.build();
}
private static IrisData loaderOf(IrisRegistrant... registrants) {
for (IrisRegistrant registrant : registrants) {
if (registrant != null && registrant.getLoader() != null) {
return registrant.getLoader();
}
}
return null;
}
private static final class State {
private IrisRiverPlacementMode placement = IrisRiverPlacementMode.NATURAL;
private IrisRiverRoutingMode routing = IrisRiverRoutingMode.ALLOW;
@@ -52,8 +78,11 @@ public final class RiverPolicyResolver {
private Double shoreBiomeWidth = null;
private RiverConfinement confinement = RiverConfinement.NONE;
/** {@code scope} is the confinement a {@code confined: true} at this level binds courses to. */
private void apply(IrisRiverPolicy policy, RiverConfinement scope) {
/**
* {@code scope} is the confinement a {@code confined: true} at this level binds courses to; {@code data} is the
* pack whose version-content gate filters the biome selections (null skips filtering).
*/
private void apply(IrisRiverPolicy policy, RiverConfinement scope, IrisData data) {
if (policy == null) {
return;
}
@@ -76,19 +105,19 @@ public final class RiverPolicyResolver {
profiles = List.copyOf(policy.getProfiles());
}
if (policy.getSurfaceBiomes() != null) {
surfaceBiomes = List.copyOf(policy.getSurfaceBiomes());
surfaceBiomes = List.copyOf(policy.compatBiomes(policy.getSurfaceBiomes(), data, "surfaceBiomes"));
}
if (policy.getMouthBiomes() != null) {
mouthBiomes = List.copyOf(policy.getMouthBiomes());
mouthBiomes = List.copyOf(policy.compatBiomes(policy.getMouthBiomes(), data, "mouthBiomes"));
}
if (policy.getShoreBiomes() != null) {
shoreBiomes = List.copyOf(policy.getShoreBiomes());
shoreBiomes = List.copyOf(policy.compatBiomes(policy.getShoreBiomes(), data, "shoreBiomes"));
}
if (policy.getBankBiomes() != null) {
bankBiomes = List.copyOf(policy.getBankBiomes());
bankBiomes = List.copyOf(policy.compatBiomes(policy.getBankBiomes(), data, "bankBiomes"));
}
if (policy.getFloodedCaveBiomes() != null) {
floodedCaveBiomes = List.copyOf(policy.getFloodedCaveBiomes());
floodedCaveBiomes = List.copyOf(policy.compatBiomes(policy.getFloodedCaveBiomes(), data, "floodedCaveBiomes"));
}
if (policy.getSurfacePools() != null) {
surfacePools = List.copyOf(policy.getSurfacePools());
@@ -171,6 +171,10 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
if (placement == null || columns == null || columns.isEmpty()) {
return;
}
if (placement.isCompatExcluded(data)) {
return;
}
int density = placement.getDensity(rng, minX, minZ, data);
double perAttempt = placement.getChance();
for (int i = 0; i < density; i++) {
@@ -217,6 +221,10 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
if (placement == null || columns.isEmpty()) {
return;
}
if (placement.isCompatExcluded(data)) {
return;
}
int density = placement.getDensity(rng, minX, minZ, data);
double perAttempt = placement.getChance();
@@ -302,6 +310,10 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent {
if (placement == null || columns.isEmpty()) {
return;
}
if (placement.isCompatExcluded(data)) {
return;
}
int density = placement.getDensity(rng, minX, minZ, data);
double perAttempt = placement.getChance();
@@ -417,7 +417,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
for (IrisObjectPlacement i : surfaceBiome.getSurfaceObjects()) {
biomeSurfaceChecked++;
boolean chance = rng.chance(i.getChance() + rng.d(-0.005, 0.005));
boolean chance = rng.chance(i.getChance() + rng.d(-0.005, 0.005)) && !i.isCompatExcluded(getData());
if (traceRegen) {
IrisLogging.debug("Regen object placer chance: chunk=" + x + "," + z
+ " scope=biome-surface"
@@ -450,7 +450,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
continue;
}
biomeCaveChecked++;
boolean chance = rng.chance(i.getChance());
boolean chance = rng.chance(i.getChance()) && !i.isCompatExcluded(getData());
if (traceRegen) {
IrisLogging.debug("Regen object placer chance: chunk=" + x + "," + z
+ " scope=biome-cave"
@@ -480,7 +480,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
for (IrisObjectPlacement i : region.getSurfaceObjects()) {
regionSurfaceChecked++;
boolean chance = rng.chance(i.getChance() + rng.d(-0.005, 0.005));
boolean chance = rng.chance(i.getChance() + rng.d(-0.005, 0.005)) && !i.isCompatExcluded(getData());
if (traceRegen) {
IrisLogging.debug("Regen object placer chance: chunk=" + x + "," + z
+ " scope=region-surface"
@@ -513,7 +513,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
continue;
}
regionCaveChecked++;
boolean chance = rng.chance(i.getChance());
boolean chance = rng.chance(i.getChance()) && !i.isCompatExcluded(getData());
if (traceRegen) {
IrisLogging.debug("Regen object placer chance: chunk=" + x + "," + z
+ " scope=region-cave"
@@ -1182,7 +1182,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
boolean forcePlace = dimension.isUpperObjectsForcePlace();
if (upperBiome != null) {
for (IrisObjectPlacement i : upperBiome.getSurfaceObjects()) {
if (!rng.chance(i.getChance() + rng.d(-0.005, 0.005))) {
if (!rng.chance(i.getChance() + rng.d(-0.005, 0.005)) || i.isCompatExcluded(getData())) {
continue;
}
try {
@@ -1197,7 +1197,7 @@ public class MantleObjectComponent extends IrisMantleComponent {
if (upperRegion != null) {
for (IrisObjectPlacement i : upperRegion.getSurfaceObjects()) {
if (!rng.chance(i.getChance() + rng.d(-0.005, 0.005))) {
if (!rng.chance(i.getChance() + rng.d(-0.005, 0.005)) || i.isCompatExcluded(getData())) {
continue;
}
try {
@@ -1635,13 +1635,13 @@ public class MantleObjectComponent extends IrisMantleComponent {
IrisRegion region = getEngineMantle().getEngine().getRegion((x << 4) + 8, (z << 4) + 8);
Set<String> v = new KSet<>();
for (IrisObjectPlacement i : biome.getSurfaceObjects()) {
if (rng.chance(i.getChance() + rng.d(-0.005, 0.005))) {
if (rng.chance(i.getChance() + rng.d(-0.005, 0.005)) && !i.isCompatExcluded(getData())) {
v.addAll(guessPlacedKeys(rng, x, z, i));
}
}
for (IrisObjectPlacement i : region.getSurfaceObjects()) {
if (rng.chance(i.getChance() + rng.d(-0.005, 0.005))) {
if (rng.chance(i.getChance() + rng.d(-0.005, 0.005)) && !i.isCompatExcluded(getData())) {
v.addAll(guessPlacedKeys(rng, x, z, i));
}
}
@@ -0,0 +1,219 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.volmlib.util.collection.KList;
import java.util.ArrayList;
import java.util.List;
/**
* Registrant reference pools filtered by the version-content gate. A reference to a registrant the gate EXCLUDED is
* dropped from every pool that could pick it and reported once against the containing registrant; a container whose
* required pool ends up empty cascades to EXCLUDED through {@link #cascade}. Cascade and drop findings reuse the
* registry and key of the cause so the console groups everything under the one missing content key.
* <p>
* Surviving entries keep their declaration order, so only the removed entries change what a pool picks.
*/
final class CompatPools {
private CompatPools() {
}
static boolean excluded(IrisRegistrant registrant) {
return registrant != null && registrant.isCompatExcluded();
}
/**
* Loads a registrant reference pool, skipping entries that fail to load and entries the gate excluded. Each
* excluded reference produces one DROPPED finding in {@code sink} (when given) and in the pack report.
*
* @param field the JSON field the keys came from, used for the finding detail ({@code landBiomes[2] key})
*/
static <T extends IrisRegistrant> KList<T> load(ResourceLoader<T> loader,
List<String> keys,
IrisData data,
String subjectType,
String subjectKey,
String field,
List<CompatFinding> sink) {
KList<T> kept = new KList<>();
if (loader == null || keys == null) {
return kept;
}
for (int index = 0; index < keys.size(); index++) {
String key = keys.get(index);
T loaded = loader.load(key);
if (loaded == null) {
continue;
}
if (loaded.isCompatExcluded()) {
drop(data, loaded, subjectType, subjectKey, field + "[" + index + "] " + key, sink);
continue;
}
kept.add(loaded);
}
return kept;
}
/** The surviving keys of a reference pool, in declaration order. */
static <T extends IrisRegistrant> KList<String> surviving(ResourceLoader<T> loader,
List<String> keys,
IrisData data,
String subjectType,
String subjectKey,
String field) {
KList<String> kept = new KList<>();
if (keys == null) {
return kept;
}
if (loader == null) {
kept.addAll(keys);
return kept;
}
for (int index = 0; index < keys.size(); index++) {
String key = keys.get(index);
T loaded = loader.load(key);
if (loaded != null && loaded.isCompatExcluded()) {
drop(data, loaded, subjectType, subjectKey, field + "[" + index + "] " + key, null);
continue;
}
kept.add(key);
}
return kept;
}
/**
* Records one DROPPED finding for a reference to an excluded registrant, reusing the registry and key of the
* child's exclusion cause. Returns null when the child carries no cause to attribute the drop to.
*/
static CompatFinding drop(IrisData data,
IrisRegistrant child,
String subjectType,
String subjectKey,
String detail,
List<CompatFinding> sink) {
CompatFinding cause = causeOf(child == null ? null : child.getCompat());
if (cause == null) {
return null;
}
CompatFinding finding = new CompatFinding(cause.registry(), cause.key(), CompatAction.DROPPED,
subjectType, subjectKey, detail);
record(data, finding);
if (sink != null) {
sink.add(finding);
}
return finding;
}
/**
* The EXCLUDED status for a container whose required pool lost every entry. Returns {@code base} untouched when no
* cause was collected, so a container that never declared the pool is not excluded for being empty.
*/
static CompatStatus cascade(IrisData data,
CompatStatus base,
List<CompatFinding> causes,
String subjectType,
String subjectKey,
String detail) {
if (causes == null || causes.isEmpty()) {
return base;
}
CompatFinding cause = causes.getFirst();
CompatFinding finding = new CompatFinding(cause.registry(), cause.key(), CompatAction.EXCLUDED,
subjectType, subjectKey, detail);
record(data, finding);
List<CompatFinding> reasons = new ArrayList<>(base == null ? List.of() : base.reasons());
reasons.add(finding);
return CompatStatus.excludedBy(reasons);
}
/** The findings on a status that dropped an entry, used as the cause set of a cascade. */
static List<CompatFinding> droppedReasons(CompatStatus status) {
List<CompatFinding> dropped = new ArrayList<>();
if (status == null) {
return dropped;
}
for (CompatFinding reason : status.reasons()) {
if (reason.action() == CompatAction.DROPPED) {
dropped.add(reason);
}
}
return dropped;
}
static void record(IrisData data, CompatFinding finding) {
if (data == null || finding == null) {
return;
}
try {
data.getCompatReport().record(finding);
} catch (Throwable ignored) {
// A report is best effort: never let reporting break a pool build.
}
}
/** The finding that explains an exclusion: the EXCLUDED reason if there is one, otherwise the first reason. */
private static CompatFinding causeOf(CompatStatus status) {
if (status == null) {
return null;
}
CompatFinding fallback = null;
for (CompatFinding reason : status.reasons()) {
if (reason.action() == CompatAction.EXCLUDED) {
return reason;
}
if (fallback == null) {
fallback = reason;
}
}
return fallback;
}
}
@@ -559,13 +559,9 @@ public class IrisBiome extends IrisRegistrant implements IRare {
public KList<IrisBiome> getRealChildren(DataProvider g) {
return realChildren.aquire(() ->
{
KList<IrisBiome> realChildren = new KList<>();
for (String i : getChildren()) {
realChildren.add(g.getData().getBiomeLoader().load(i));
}
return realChildren;
IrisData data = g == null ? null : g.getData();
return CompatPools.load(data == null ? null : data.getBiomeLoader(), getChildren(), data,
"biome", getLoadKey(), "children", null);
});
}
@@ -18,6 +18,8 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.compat.KeyStatus;
import art.arcane.iris.core.nms.datapack.IDataFixer;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.DependsOn;
@@ -132,6 +134,16 @@ public class IrisBiomeCustom {
}
public String generateJson(IDataFixer fixer) {
return generateJson(fixer, null);
}
/**
* @param gate version-content gate used to keep spawns whose entity type is missing on this server out of the
* generated datapack (the game rejects a datapack that names an unknown entity). Null consults the
* live platform registries; an unreadable registry keeps every spawn.
*/
public String generateJson(IDataFixer fixer, ContentGate gate) {
ContentGate contentGate = gate == null ? ContentGate.forData(null) : gate;
JSONObject effects = new JSONObject();
effects.put("sky_color", parseColor(getSkyColor()));
effects.put("fog_color", parseColor(getFogColor()));
@@ -196,6 +208,10 @@ public class IrisBiomeCustom {
IrisLogging.warn("Skipping custom biome spawn with null entity type in biome " + getId());
continue;
}
if (contentGate.entity(key) == KeyStatus.MISSING) {
IrisLogging.debug("Dropping custom biome spawn " + key + " in biome " + getId() + ": entity missing on this server");
continue;
}
IrisBiomeCustomSpawnType group = i.getGroup() == null ? IrisBiomeCustomSpawnType.MISC : i.getGroup();
JSONArray g = groups.computeIfAbsent(group, (k) -> new JSONArray());
JSONObject o = new JSONObject();
@@ -19,6 +19,7 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.link.Identifier;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.IrisRegistrant;
@@ -141,10 +142,15 @@ public class IrisBlockData extends IrisRegistrant {
return computeProperties(getData());
}
/** The full state this entry names, {@code namespace:block[props]}, as the gate and the registries expect it. */
public String stateKey() {
return keyify(getBlock()) + computeProperties();
}
public PlatformBlockState getBlockData(IrisData data) {
return blockdata.aquire(() ->
{
IrisBlockData customData = data.getBlockLoader().load(getBlock(), false);
IrisBlockData customData = data.getBlockLoader() == null ? null : data.getBlockLoader().load(getBlock(), false);
if (customData != null) {
PlatformBlockState customState = customData.getBlockData(data);
@@ -168,7 +174,7 @@ public class IrisBlockData extends IrisRegistrant {
IrisLogging.debug("Block Data used " + sx + " (CUSTOM)");
}
PlatformBlockState bx = B.getState(sx);
PlatformBlockState bx = resolve(data, sx);
if (bx != null) {
return bx;
@@ -178,8 +184,8 @@ public class IrisBlockData extends IrisRegistrant {
}
}
String ss = keyify(getBlock()) + computeProperties();
PlatformBlockState resolved = B.getState(ss);
String ss = stateKey();
PlatformBlockState resolved = resolve(data, ss);
if (debug) {
IrisLogging.debug("Block Data used " + ss);
@@ -193,10 +199,42 @@ public class IrisBlockData extends IrisRegistrant {
return backup.getBlockData(data);
}
return B.getState("AIR");
IrisLogging.warnOnce("block-missing:" + ss, "Block " + ss + " does not exist on this server and has no backup; generating air");
return B.getAirState();
});
}
/**
* {@link #getBlockData(IrisData)} for match sides such as {@code edit} find lists: a key missing on this server
* (after custom blocks, the gate chain and every backup) yields a {@link art.arcane.iris.core.compat.MissingBlockState}
* carrying the key instead of air, so a palette placeholder with the same key can still be matched and rewritten.
*/
public PlatformBlockState getBlockDataOrPlaceholder(IrisData data) {
ContentGate gate = data.getContentGate();
String state = stateKey();
if (!gate.ready() || hasCustomBlock(data) || gate.resolveBlock(state) != null) {
return getBlockData(data);
}
if (backup != null) {
return backup.getBlockDataOrPlaceholder(data);
}
PlatformBlockState placeholder = gate.resolveBlockOrPlaceholder(state);
return placeholder == null ? getBlockData(data) : placeholder;
}
private boolean hasCustomBlock(IrisData data) {
return data.getBlockLoader() != null && data.getBlockLoader().load(getBlock(), false) != null;
}
/** Registry, legacy rename table, then dimension blockFallbacks; null when the state is missing on this server. */
private static PlatformBlockState resolve(IrisData data, String state) {
ContentGate.BlockResolution resolution = data.getContentGate().resolveBlock(state);
return resolution == null ? null : resolution.state();
}
public TileData tryGetTile(IrisData data) {
//TODO Do like a registry thing with the tile data registry. Also update the parsing of data to include **block** entities.
PlatformBlockState state = getBlockData(data);
@@ -18,6 +18,7 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.LegacyBlockRenames;
import art.arcane.iris.platform.bukkit.BukkitBlockResolution;
import art.arcane.iris.spi.IrisLogging;
@@ -121,144 +122,9 @@ public class IrisCompat {
private static KList<IrisCompatabilityBlockFilter> getDefaultBlockCompatabilityFilters() {
KList<IrisCompatabilityBlockFilter> filters = new KList<>();
filters.add(new IrisCompatabilityBlockFilter("CHAIN", "IRON_CHAIN"));
filters.add(new IrisCompatabilityBlockFilter("GRASS", "SHORT_GRASS"));
filters.add(new IrisCompatabilityBlockFilter("SHORT_GRASS", "GRASS"));
// Below 1.16
filters.add(new IrisCompatabilityBlockFilter("WEEPING_VINES", "NETHER_FENCE"));
filters.add(new IrisCompatabilityBlockFilter("WEEPING_VINES_PLANT", "NETHER_FENCE"));
filters.add(new IrisCompatabilityBlockFilter("WARPED_WART_BLOCK", "NETHER_WART_BLOCK"));
filters.add(new IrisCompatabilityBlockFilter("TWISTING_VINES", "BAMBOO"));
filters.add(new IrisCompatabilityBlockFilter("TWISTING_VINES_PLANT", "BAMBOO"));
filters.add(new IrisCompatabilityBlockFilter("TARGET", "COBBLESTONE"));
filters.add(new IrisCompatabilityBlockFilter("SOUL_SOIL", "SOULSAND"));
filters.add(new IrisCompatabilityBlockFilter("SOUL_TORCH", "TORCH"));
filters.add(new IrisCompatabilityBlockFilter("SOUL_LANTERN", "LANTERN"));
filters.add(new IrisCompatabilityBlockFilter("SOUL_FIRE", "FIRE"));
filters.add(new IrisCompatabilityBlockFilter("SOUL_CAMPFIRE", "CAMPFIRE"));
filters.add(new IrisCompatabilityBlockFilter("SHROOMLIGHT", "GLOWSTONE"));
filters.add(new IrisCompatabilityBlockFilter("RESPAWN_ANCHOR", "OBSIDIAN"));
filters.add(new IrisCompatabilityBlockFilter("NETHER_SPROUTS", "RED_MUSHROOM"));
filters.add(new IrisCompatabilityBlockFilter("NETHER_GOLD_ORE", "GOLD_ORE"));
filters.add(new IrisCompatabilityBlockFilter("LODESTONE", "STONE"));
filters.add(new IrisCompatabilityBlockFilter("STRIPPED_WARPED_HYPHAE", "BROWN_MUSHROOM_BLOCK"));
filters.add(new IrisCompatabilityBlockFilter("STRIPPED_CRIMSON_HYPHAE", "RED_MUSHROOM_BLOCK"));
filters.add(new IrisCompatabilityBlockFilter("WARPED_HYPHAE", "MUSHROOM_STEM"));
filters.add(new IrisCompatabilityBlockFilter("CRIMSON_HYPHAE", "RED_MUSHROOM_BLOCK"));
filters.add(new IrisCompatabilityBlockFilter("GILDED_BLACKSTONE", "COBBLESTONE"));
filters.add(new IrisCompatabilityBlockFilter("CRYING_OBSIDIAN", "OBSIDIAN"));
filters.add(new IrisCompatabilityBlockFilter("STRIPPED_WARPED_STEM", "MUSHROOM_STEM"));
filters.add(new IrisCompatabilityBlockFilter("STRIPPED_CRIMSON_STEM", "MUSHROOM_STEM"));
filters.add(new IrisCompatabilityBlockFilter("WARPED_STEM", "MUSHROOM_STEM"));
filters.add(new IrisCompatabilityBlockFilter("CRIMSON_STEM", "MUSHROOM_STEM"));
filters.add(new IrisCompatabilityBlockFilter("CRIMSON_ROOTS", "RED_MUSHROOM"));
filters.add(new IrisCompatabilityBlockFilter("WARPED_ROOTS", "BROWN_MUSHROOM"));
filters.add(new IrisCompatabilityBlockFilter("CRIMSON_PLANKS", "OAK_PLANKS"));
filters.add(new IrisCompatabilityBlockFilter("WARPED_PLANKS", "OAK_PLANKS"));
filters.add(new IrisCompatabilityBlockFilter("WARPED_NYLIUM", "MYCELIUM"));
filters.add(new IrisCompatabilityBlockFilter("CRIMSON_NYLIUM", "MYCELIUM"));
filters.add(new IrisCompatabilityBlockFilter("WARPED_FUNGUS", "BROWN_MUSHROOM"));
filters.add(new IrisCompatabilityBlockFilter("CRIMSON_FUNGUS", "RED_MUSHROOM"));
filters.add(new IrisCompatabilityBlockFilter("CRACKED_NETHER_BRICKS", "NETHER_BRICKS"));
filters.add(new IrisCompatabilityBlockFilter("CHISELED_NETHER_BRICKS", "NETHER_BRICKS"));
filters.add(new IrisCompatabilityBlockFilter("NETHER_FENCE", "LEGACY_NETHER_FENCE"));
filters.add(new IrisCompatabilityBlockFilter("IRON_CHAIN", "IRON_BARS"));
filters.add(new IrisCompatabilityBlockFilter("NETHERITE_BLOCK", "QUARTZ_BLOCK"));
filters.add(new IrisCompatabilityBlockFilter("BLACKSTONE", "COBBLESTONE"));
filters.add(new IrisCompatabilityBlockFilter("BASALT", "STONE"));
filters.add(new IrisCompatabilityBlockFilter("ANCIENT_DEBRIS", "NETHERRACK"));
filters.add(new IrisCompatabilityBlockFilter("NETHERRACK", "LEGACY_NETHERRACK"));
// Below 1.15
filters.add(new IrisCompatabilityBlockFilter("HONEY_BLOCK", "OAK_LEAVES"));
filters.add(new IrisCompatabilityBlockFilter("BEEHIVE", "OAK_LEAVES"));
filters.add(new IrisCompatabilityBlockFilter("BEE_NEST", "OAK_LEAVES"));
// Below 1.14
filters.add(new IrisCompatabilityBlockFilter("GRANITE_WALL", "COBBLESTONE_WALL"));
filters.add(new IrisCompatabilityBlockFilter("BLUE_ICE", "PACKED_ICE"));
filters.add(new IrisCompatabilityBlockFilter("DIORITE_WALL", "COBBLESTONE_WALL"));
filters.add(new IrisCompatabilityBlockFilter("ANDESITE_WALL", "COBBLESTONE_WALL"));
filters.add(new IrisCompatabilityBlockFilter("SWEET_BERRY_BUSH", "GRASS"));
filters.add(new IrisCompatabilityBlockFilter("STONECUTTER", "CRAFTING_TABLE"));
filters.add(new IrisCompatabilityBlockFilter("SANDSTONE_STAIRS", "LEGACY_SANDSTONE_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("SMOOTH_SANDSTONE_STAIRS", "LEGACY_SANDSTONE_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("MOSSY_COBBLESTONE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("MOSSY_STONE_BRICK_STAIRS", "STONE_BRICK_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("POLISHED_GRANITE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("GRANITE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("POLISHED_DIORITE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("DIORITE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("POLISHED_ANDESITE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("ANDESITE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("STONE_STAIRS", "COBBLESTONE_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("END_STONE_BRICK_STAIRS", "LEGACY_SANDSTONE_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("NETHER_BRICK_STAIRS", "LEGACY_NETHER_BRICK_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("RED_NETHER_BRICK_STAIRS", "NETHER_BRICK_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("SMOOTH_QUARTZ_STAIRS", "LEGACY_QUARTZ_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("QUARTZ_STAIRS", "LEGACY_QUARTZ_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("RED_SANDSTONE_STAIRS", "LEGACY_RED_SANDSTONE_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("SMOOTH_RED_SANDSTONE_STAIRS", "LEGACY_RED_SANDSTONE_STAIRS"));
filters.add(new IrisCompatabilityBlockFilter("STONE_SLAB", "SMOOTH_STONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("SMOKER", "FURNACE"));
filters.add(new IrisCompatabilityBlockFilter("SMITHING_TABLE", "CRAFTING_TABLE"));
filters.add(new IrisCompatabilityBlockFilter("END_STONE_BRICK_SLAB", "SANDSTONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("RED_NETHER_BRICK_SLAB", "NETHER_BRICK_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("SMOOTH_QUARTZ_SLAB", "QUARTZ_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("CUT_SANDSTONE_SLAB", "SANDSTONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("CUT_RED_SANDSTONE_SLAB", "RED_SANDSTONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("SMOOTH_RED_SANDSTONE_SLAB", "RED_SANDSTONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("SMOOTH_SANDSTONE_SLAB", "SANDSTONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("MOSSY_COBBLESTONE_SLAB", "COBBLESTONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("MOSSY_STONE_BRICK_SLAB", "STONE_BRICK_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("STONE_SLAB", "SMOOTH_STONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("ANDESITE_SLAB", "COBBLESTONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("ANDESITE_SLAB", "COBBLESTONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("DIORITE_SLAB", "COBBLESTONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("GRANITE_SLAB", "COBBLESTONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("POLISHED_ANDESITE_SLAB", "SMOOTH_STONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("POLISHED_DIORITE_SLAB", "SMOOTH_STONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("POLISHED_GRANITE_SLAB", "SMOOTH_STONE_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("WARPED_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new IrisCompatabilityBlockFilter("WARPED_SIGN", "LEGACY_SIGN_POST"));
filters.add(new IrisCompatabilityBlockFilter("SPRUCE_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new IrisCompatabilityBlockFilter("SPRUCE_SIGN", "LEGACY_SIGN_POST"));
filters.add(new IrisCompatabilityBlockFilter("OAK_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new IrisCompatabilityBlockFilter("OAK_SIGN", "LEGACY_SIGN_POST"));
filters.add(new IrisCompatabilityBlockFilter("JUNGLE_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new IrisCompatabilityBlockFilter("JUNGLE_SIGN", "LEGACY_SIGN_POST"));
filters.add(new IrisCompatabilityBlockFilter("DARK_OAK_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new IrisCompatabilityBlockFilter("DARK_OAK_SIGN", "LEGACY_SIGN_POST"));
filters.add(new IrisCompatabilityBlockFilter("CRIMSON_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new IrisCompatabilityBlockFilter("CRIMSON_SIGN", "LEGACY_SIGN_POST"));
filters.add(new IrisCompatabilityBlockFilter("BIRCH_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new IrisCompatabilityBlockFilter("BIRCH_SIGN", "LEGACY_SIGN_POST"));
filters.add(new IrisCompatabilityBlockFilter("ACACIA_WALL_SIGN", "LEGACY_WALL_SIGN"));
filters.add(new IrisCompatabilityBlockFilter("ACACIA_SIGN", "LEGACY_SIGN_POST"));
filters.add(new IrisCompatabilityBlockFilter("SCAFFOLDING", "BIRCH_FENCE"));
//filters.add(new IrisCompatabilityBlockFilter("LOOM", "LOOM"));
filters.add(new IrisCompatabilityBlockFilter("LECTERN", "BOOKSHELF"));
filters.add(new IrisCompatabilityBlockFilter("LANTERN", "REDSTONE_LAMP"));
filters.add(new IrisCompatabilityBlockFilter("JIGSAW", "AIR"));
filters.add(new IrisCompatabilityBlockFilter("GRINDSTONE", "COBBLESTONE"));
filters.add(new IrisCompatabilityBlockFilter("FLETCHING_TABLE", "CRAFTING_TABLE"));
filters.add(new IrisCompatabilityBlockFilter("COMPOSTER", "CHEST"));
filters.add(new IrisCompatabilityBlockFilter("CARTOGRAPHY_TABLE", "CRAFTING_TABLE"));
filters.add(new IrisCompatabilityBlockFilter("CAMPFIRE", "DARK_OAK_SLAB"));
filters.add(new IrisCompatabilityBlockFilter("BLAST_FURNACE", "FURNACE"));
filters.add(new IrisCompatabilityBlockFilter("BELL", "REDSTONE_LAMP"));
filters.add(new IrisCompatabilityBlockFilter("minecraft:barrel[facing=south]", "minecraft:hay_bale[axis=z]", true));
filters.add(new IrisCompatabilityBlockFilter("minecraft:barrel[facing=north]", "minecraft:hay_bale[axis=z]", true));
filters.add(new IrisCompatabilityBlockFilter("minecraft:barrel[facing=east]", "minecraft:hay_bale[axis=x]", true));
filters.add(new IrisCompatabilityBlockFilter("minecraft:barrel[facing=west]", "minecraft:hay_bale[axis=x]", true));
filters.add(new IrisCompatabilityBlockFilter("minecraft:barrel[facing=up]", "minecraft:hay_bale[axis=y]", true));
filters.add(new IrisCompatabilityBlockFilter("minecraft:barrel[facing=down]", "minecraft:hay_bale[axis=y]", true));
filters.add(new IrisCompatabilityBlockFilter("BAMBOO", "BIRCH_FENCE"));
filters.add(new IrisCompatabilityBlockFilter("BAMBOO_SAPLING", "BIRCH_SAPLING"));
filters.add(new IrisCompatabilityBlockFilter("POTTED_BAMBOO", "POTTED_BIRCH_SAPLING"));
for (LegacyBlockRenames.Rename rename : LegacyBlockRenames.defaults()) {
filters.add(new IrisCompatabilityBlockFilter(rename.when(), rename.supplement(), rename.exact()));
}
return filters;
}
@@ -19,6 +19,9 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.IrisDatapackCompiler.DimensionHeight;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBlockState;
@@ -60,10 +63,12 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@@ -126,6 +131,8 @@ public class IrisDimension extends IrisRegistrant {
@MinNumber(0)
@Desc("The version of this dimension. Changing this will stop users from accidentally upgrading (and breaking their worlds).")
private int version = 1;
@Desc("When a block key does not exist on the running Minecraft version, generate this block state instead. Keys are base block keys such as minecraft:sulfur; values are full block states. Applies to every palette, decorator, deposit, replace rule and object in this pack. Content that uses a missing block with no fallback here (and no per-entry backup) is excluded from generation on that version and listed at startup.")
private KMap<String, String> blockFallbacks = new KMap<>();
@ArrayType(min = 1, type = IrisBlockDrops.class)
@Desc("Define custom block drops for this dimension")
private KList<IrisBlockDrops> blockDrops = new KList<>();
@@ -307,6 +314,25 @@ public class IrisDimension extends IrisRegistrant {
return staticObjectLayer.aquire(() -> IrisStaticObjectLayer.compile(this, data));
}
/**
* Runs the version-content gate over every static object so the pack report lists the entries this server cannot
* place. The static layer makes the same decision when it compiles; validation calls this on its detached loader
* so the boot listing is complete without compiling a layer.
*/
public void evaluateStaticObjectsCompat(IrisData data) {
if (staticObjects == null) {
return;
}
for (int index = 0; index < staticObjects.size(); index++) {
IrisStaticObject entry = staticObjects.get(index);
if (entry != null) {
entry.evaluateCompat(data, getLoadKey(), index);
}
}
}
public IrisDimension setStaticObjects(KList<IrisStaticObject> staticObjects) {
this.staticObjects = staticObjects;
staticObjectLayer.reset();
@@ -473,11 +499,18 @@ public class IrisDimension extends IrisRegistrant {
return regions.v();
}
for (String key : getRegions()) {
for (int index = 0; index < getRegions().size(); index++) {
String key = getRegions().get(index);
IrisRegion region = data.getRegionLoader().load(key);
if (region != null) {
regions.put(key, region);
if (region == null) {
continue;
}
if (region.isCompatExcluded()) {
CompatPools.drop(data, region, "dimension", getLoadKey(),
"regions[" + index + "] " + key, null);
continue;
}
regions.put(key, region);
}
for (IrisImageMapBinding binding : getImageMaps()) {
if (binding == null || binding.getApplication() != IrisImageMapApplication.REGION) {
@@ -490,7 +523,7 @@ public class IrisDimension extends IrisRegistrant {
for (String target : imageMapTargets(map)) {
String key = localImageMapTarget(target);
IrisRegion region = data.getRegionLoader().load(key);
if (region != null) {
if (region != null && !region.isCompatExcluded()) {
regions.put(key, region);
}
}
@@ -707,9 +740,11 @@ public class IrisDimension extends IrisRegistrant {
// comes from the chunk generator's generation-settings getter, not from the datapack.
String derivativeKey = irisBiome.getVanillaDerivativeKey();
ContentGate contentGate = getLoader() == null ? null : getLoader().getContentGate();
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
String customBiomeId = customBiome.getId();
String json = customBiome.generateJson(fixer);
String json = customBiome.generateJson(fixer, contentGate);
synchronized (biomes) {
if (!biomes.add(customBiomeId)) {
@@ -905,6 +940,39 @@ public class IrisDimension extends IrisRegistrant {
}
}
/** The declared region pool with unloadable and gate-excluded regions removed; drops are reported once. */
private KList<IrisRegion> resolveRegionPool(IrisData data, List<CompatFinding> sink) {
return CompatPools.load(data == null ? null : data.getRegionLoader(), getRegions(), data,
"dimension", getLoadKey(), "regions", sink);
}
/**
* Cascade: a dimension whose every region was excluded cannot generate anything. Pack validation turns this into a
* blocking error. Loading regions here is safe - regions never load dimensions.
*/
@Override
public CompatStatus evaluateCompat(ContentGate gate) {
CompatStatus base = super.evaluateCompat(gate);
if (base.excluded()) {
return base;
}
IrisData data = getLoader();
if (data == null || getRegions().isEmpty()) {
return base;
}
List<CompatFinding> drops = new ArrayList<>();
if (!resolveRegionPool(data, drops).isEmpty()) {
return base;
}
return CompatPools.cascade(data, base, drops, "dimension", getLoadKey(), "no regions remain");
}
@Override
public String getFolderName() {
return "dimensions";
@@ -63,7 +63,14 @@ public class IrisDimensionCarvingEntry {
return null;
}
return data.getBiomeLoader().load(biomeKey.trim());
IrisBiome loaded = data.getBiomeLoader().load(biomeKey.trim());
if (loaded != null && loaded.isCompatExcluded()) {
CompatPools.drop(data, loaded, "carving entry", getId(), "biome " + biomeKey.trim(), null);
return null;
}
return loaded;
});
}
@@ -18,6 +18,7 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.engine.data.cache.AtomicCache;
@@ -147,7 +148,17 @@ public class IrisEntitySpawn implements IRare {
}
public IrisEntity getRealEntity(Engine g) {
return ent.aquire(() -> g.getData().getEntityLoader().load(getEntity()));
return ent.aquire(() -> resolveEntity(g.getData()));
}
/** The referenced entity, or null when it does not load or the version-content gate excluded it. */
IrisEntity resolveEntity(IrisData data) {
if (data == null || data.getEntityLoader() == null) {
return null;
}
IrisEntity entity = data.getEntityLoader().load(getEntity());
return entity == null || entity.isCompatExcluded() ? null : entity;
}
public Entity spawn(Engine g, Location at) {
@@ -358,6 +358,11 @@ public class IrisFloatingChildBiomes implements IRare {
return parent;
}
if (loaded.isCompatExcluded()) {
CompatPools.drop(data, loaded, "floating child biome", parent.getLoadKey(), "biome " + biome, null);
return parent;
}
return loaded;
});
}
@@ -18,6 +18,12 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatRegistry;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
@@ -81,6 +87,42 @@ public class IrisJigsawPiece extends IrisRegistrant {
return rules == null ? new IrisJigsawPieceRules() : rules;
}
/**
* A piece is one object stamped verbatim - nothing rewrites its blocks - so a palette key the server does not
* have (after the pack's declared fallbacks) takes the whole piece out of every pool that lists it.
*/
@Override
public CompatStatus evaluateCompat(ContentGate gate) {
CompatStatus base = super.evaluateCompat(gate);
if (base.excluded() || gate == null || !gate.ready()) {
return base;
}
IrisData data = getLoader();
if (data == null || object == null || object.isBlank()) {
return base;
}
for (String paletteKey : IrisObjectIO.readPaletteKeysCached(data.getObjectLoader().findFile(object.trim()))) {
String key = ContentGate.baseKey(ContentGate.normalizeState(paletteKey));
if (key == null || gate.resolveBlock(key) != null) {
continue;
}
CompatFinding finding = new CompatFinding(CompatRegistry.BLOCK, key, CompatAction.EXCLUDED,
"jigsaw piece", getLoadKey(), "object " + object.trim());
gate.report().record(finding);
KList<CompatFinding> reasons = new KList<>(base.reasons());
reasons.add(finding);
return CompatStatus.excludedBy(reasons);
}
return base;
}
@Override
public String getFolderName() {
return "jigsaw-pieces";
@@ -18,6 +18,12 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatRegistry;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
@@ -51,6 +57,75 @@ public class IrisJigsawPool extends IrisRegistrant {
return structureRequiresCaps || mandatoryFallback;
}
/**
* Excluded pieces drop out of the pool; a pool with nothing left to place is excluded in turn so every connector
* that targets it terminates instead of failing assembly.
*/
@Override
public CompatStatus evaluateCompat(ContentGate gate) {
CompatStatus base = super.evaluateCompat(gate);
if (base.excluded() || gate == null || !gate.ready()) {
return base;
}
IrisData data = getLoader();
if (data == null || pieces == null || pieces.isEmpty()) {
return base;
}
KList<CompatFinding> reasons = new KList<>(base.reasons());
CompatFinding cause = null;
int usable = 0;
for (int i = 0; i < pieces.size(); i++) {
IrisJigsawPieceEntry entry = pieces.get(i);
if (entry == null) {
continue;
}
String key = entry.getPiece() == null ? "" : entry.getPiece().trim();
// An empty membership terminates a branch on purpose, and a piece key that does not resolve at all is a
// pack error the loader already reports - neither is a version-content problem.
if (entry.isEmpty() || key.isEmpty()) {
usable++;
continue;
}
IrisJigsawPiece piece = data.load(IrisJigsawPiece.class, key, false);
if (piece == null || !piece.isCompatExcluded()) {
usable++;
continue;
}
CompatFinding reason = piece.getCompat().reasons().isEmpty() ? null : piece.getCompat().reasons().getFirst();
CompatFinding dropped = new CompatFinding(
reason == null ? CompatRegistry.BLOCK : reason.registry(),
reason == null ? key : reason.key(),
CompatAction.DROPPED, "jigsaw pool", getLoadKey(), "pieces[" + i + "] " + key);
gate.report().record(dropped);
reasons.add(dropped);
if (cause == null) {
cause = dropped;
}
}
if (usable > 0 || cause == null) {
return new CompatStatus(false, reasons);
}
CompatFinding excluded = new CompatFinding(cause.registry(), cause.key(), CompatAction.EXCLUDED,
"jigsaw pool", getLoadKey(), "no pieces remain");
gate.report().record(excluded);
reasons.add(excluded);
return CompatStatus.excludedBy(reasons);
}
@Override
public String getFolderName() {
return "jigsaw-pools";
@@ -18,6 +18,7 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.data.cache.AtomicCache;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
@@ -57,9 +58,25 @@ public class IrisLootReference {
return tt.aquire(() ->
{
KList<IrisLootTable> t = new KList<>();
IrisData data = g == null ? null : g.getData();
if (data == null || data.getLootLoader() == null) {
return t;
}
for (String i : tables) {
t.add(g.getData().getLootLoader().load(i));
IrisLootTable table = data.getLootLoader().load(i);
if (table == null) {
continue;
}
if (table.isCompatExcluded()) {
CompatPools.drop(data, table, "loot reference", table.getLoadKey(), "tables " + i, null);
continue;
}
t.add(table);
}
return t;
@@ -18,6 +18,8 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.engine.framework.LootResolver;
import art.arcane.iris.engine.object.annotations.ArrayType;
@@ -103,6 +105,25 @@ public class IrisLootTable extends IrisRegistrant {
return lootf;
}
/**
* Cascade: the gate walker drops loot entries whose item is missing on this server. A table with nothing left to
* roll leaves every reference pool that could pick it.
*/
@Override
public CompatStatus evaluateCompat(ContentGate gate) {
return cascadeEmptyLoot(super.evaluateCompat(gate));
}
/** EXCLUDED when the walker dropped every loot entry; the walker's verdict is passed in as {@code base}. */
CompatStatus cascadeEmptyLoot(CompatStatus base) {
if (base.excluded() || !loot.isEmpty()) {
return base;
}
return CompatPools.cascade(getLoader(), base, CompatPools.droppedReasons(base),
"loot", getLoadKey(), "no loot entries remain");
}
@Override
public String getFolderName() {
return "loot";
@@ -18,6 +18,9 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
@@ -104,6 +107,49 @@ public class IrisMod extends IrisRegistrant {
@Desc("Replace noise styles with other styles")
private KList<IrisModNoiseStyleReplacer> styleReplacers = new KList<>();
/**
* A pack modification only references other registrants, so it never cascades: excluded biome and region
* references are dropped from its lists and reported, and the modification keeps applying whatever is left.
*/
@Override
public CompatStatus evaluateCompat(ContentGate gate) {
CompatStatus base = super.evaluateCompat(gate);
IrisData data = getLoader();
if (data == null) {
return base;
}
removeBiomes = biomes(data, removeBiomes, "removeBiomes");
removeRegions = regions(data, removeRegions, "removeRegions");
injectRegions = regions(data, injectRegions, "injectRegions");
for (int index = 0; index < biomeInjectors.size(); index++) {
IrisModBiomeInjector injector = biomeInjectors.get(index);
injector.setInject(biomes(data, injector.getInject(), "biomeInjectors[" + index + "].inject"));
}
for (int index = 0; index < biomeReplacers.size(); index++) {
IrisModBiomeReplacer replacer = biomeReplacers.get(index);
replacer.setFind(biomes(data, replacer.getFind(), "biomeReplacers[" + index + "].find"));
}
for (int index = 0; index < regionReplacers.size(); index++) {
IrisModRegionReplacer replacer = regionReplacers.get(index);
replacer.setFind(regions(data, replacer.getFind(), "regionReplacers[" + index + "].find"));
}
return base;
}
private KList<String> biomes(IrisData data, KList<String> keys, String field) {
return CompatPools.surviving(data.getBiomeLoader(), keys, data, "mod", getLoadKey(), field);
}
private KList<String> regions(IrisData data, KList<String> keys, String field) {
return CompatPools.surviving(data.getRegionLoader(), keys, data, "mod", getLoadKey(), field);
}
@Override
public String getFolderName() {
return "mods";
@@ -19,6 +19,8 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.engine.data.cache.AtomicCache;
import art.arcane.iris.platform.bukkit.BukkitBlockState;
@@ -367,4 +369,14 @@ public class IrisObject extends IrisRegistrant {
public String getTypeName() {
return "Object";
}
/**
* Objects are never gated on their own: whether a palette key that is missing on this server matters depends on
* the placement that stamps it (its {@code edit} rules may rewrite it), so the verdict is made per placement and
* per jigsaw piece. Overridden to keep the generic walker out of the block map as well.
*/
@Override
public CompatStatus evaluateCompat(ContentGate gate) {
return CompatStatus.OK;
}
}
@@ -0,0 +1,218 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatRegistry;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.compat.PackCompatReport;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.volmlib.util.collection.KList;
/**
* The per-object half of the version-content gate, shared by {@link IrisObjectPlacement} pools and
* {@link IrisStaticObject} entries: whether the blocks a placement itself generates ({@code edit[].replace}) resolve
* on this server, and which keys of one .iob palette still cannot reach the world after those edit rules and the
* fallback chain.
*/
final class IrisObjectCompat {
private IrisObjectCompat() {
}
/**
* The placement's own composition blocks: the {@code edit[].replace} palettes. {@code edit[].find} and
* {@code markers[].mark} are match sides and never count - matching a block the server does not have is harmless.
*
* @param detailPrefix text put before the {@code edit[i].replace.palette[j]} path in every finding, or empty
* @return the finding that excludes the subject, or null when every declared block resolves
*/
static CompatFinding evaluateEditPalettes(KList<IrisObjectReplace> edit, IrisData data, ContentGate gate,
String subjectType, String subjectKey, String detailPrefix,
KList<CompatFinding> reasons) {
if (edit == null) {
return null;
}
CompatFinding blocked = null;
for (int i = 0; i < edit.size(); i++) {
IrisObjectReplace rule = edit.get(i);
if (rule == null || rule.getReplace() == null || rule.getReplace().getPalette() == null) {
continue;
}
KList<IrisBlockData> palette = rule.getReplace().getPalette();
for (int j = 0; j < palette.size(); j++) {
CompatFinding finding = evaluateBlockEntry(palette.get(j), data, gate, subjectType, subjectKey,
detailPrefix + "edit[" + i + "].replace.palette[" + j + "]", reasons);
if (finding != null && blocked == null) {
blocked = finding;
}
}
}
return blocked;
}
/** Records what happened to one declared block entry; returns the finding when it excludes the subject. */
private static CompatFinding evaluateBlockEntry(IrisBlockData block, IrisData data, ContentGate gate,
String subjectType, String subjectKey, String detail,
KList<CompatFinding> reasons) {
if (block == null) {
return null;
}
BlockCompat resolution = resolveBlockEntry(block, data, gate, new KList<>());
if (resolution.missing()) {
CompatFinding finding = new CompatFinding(CompatRegistry.BLOCK, resolution.key(), CompatAction.EXCLUDED,
subjectType, subjectKey, detail);
reasons.add(finding);
return finding;
}
if (resolution.substituted()) {
reasons.add(new CompatFinding(CompatRegistry.BLOCK, resolution.key(), CompatAction.SUBSTITUTED,
subjectType, subjectKey, detail + " (using " + resolution.replacement() + ")"));
}
return null;
}
/**
* Every distinct palette key of {@code objectKey} that cannot reach the world, in palette order; empty when the
* object still places. A key survives when the gate resolves it (dimension fallbacks count) or when an
* {@code edit} rule with chance 1 matches it - that rule's replace palette was already verified by
* {@link #evaluateEditPalettes}. A rule below chance 1 does not cover the key: the original block can still be
* rolled. Substitutions are recorded against {@code subjectType}/{@code subjectKey}.
*/
static KList<String> unplaceableKeys(String objectKey, KList<IrisObjectReplace> edit, IrisData data,
ContentGate gate, String subjectType, String subjectKey, String detail,
KList<CompatFinding> reasons) {
KList<String> missing = new KList<>();
// Palette header only - no IrisObject is built and no state is resolved. A legacy (V1) object reads as an
// empty palette and is therefore never dropped; V1 has no palette block to scan without a full load.
for (String paletteKey : IrisObjectIO.readPaletteKeysCached(data.getObjectLoader().findFile(objectKey))) {
String base = ContentGate.baseKey(ContentGate.normalizeState(paletteKey));
if (base == null) {
continue;
}
// Existence is a registry question about the block, never about its properties: a property that was
// renamed on a block the server still has is out of scope for the gate.
ContentGate.BlockResolution resolution = gate.resolveBlock(base);
if (resolution != null) {
if (resolution.substituted()) {
reasons.add(new CompatFinding(CompatRegistry.BLOCK, base, CompatAction.SUBSTITUTED,
subjectType, subjectKey, detail + " (using " + resolution.resolvedKey() + ")"));
}
continue;
}
if (coveredByEdit(edit, paletteKey)) {
continue;
}
missing.addIfMissing(base);
}
return missing;
}
/** True when an {@code edit} rule certainly rewrites this palette state before it is written. */
static boolean coveredByEdit(KList<IrisObjectReplace> edit, String paletteKey) {
if (edit == null) {
return false;
}
for (IrisObjectReplace rule : edit) {
if (rule == null || rule.getChance() < 1F) {
continue;
}
if (rule.matchesState(paletteKey)) {
return true;
}
}
return false;
}
/**
* Mirrors {@code IrisBlockData.getBlockData} against the gate: pack custom block first, then the registry with
* dimension fallbacks, then the entry's own {@code backup}.
*/
static BlockCompat resolveBlockEntry(IrisBlockData block, IrisData data, ContentGate gate, KList<IrisBlockData> seen) {
for (IrisBlockData visited : seen) {
if (visited == block) {
return new BlockCompat(String.valueOf(block.getBlock()), null, true, false);
}
}
seen.add(block);
String declared = block.getBlock();
IrisBlockData custom = data.getBlockLoader() == null ? null : data.getBlockLoader().load(declared, false);
if (custom != null && custom != block) {
return resolveBlockEntry(custom, data, gate, seen);
}
String base = ContentGate.baseKey(ContentGate.normalizeState(declared));
if (base == null) {
return new BlockCompat(String.valueOf(declared), null, true, false);
}
ContentGate.BlockResolution resolution = gate.resolveBlock(base);
if (resolution != null) {
return new BlockCompat(base, resolution.resolvedKey(), false, resolution.substituted());
}
IrisBlockData backup = block.getBackup();
if (backup != null) {
BlockCompat viaBackup = resolveBlockEntry(backup, data, gate, seen);
if (!viaBackup.missing()) {
return new BlockCompat(base, viaBackup.replacement(), false, true);
}
}
return new BlockCompat(base, null, true, false);
}
static void record(PackCompatReport report, KList<CompatFinding> findings) {
for (CompatFinding finding : findings) {
report.record(finding);
}
}
/** One block entry's verdict: the key asked for, what will actually generate, and how it got there. */
record BlockCompat(String key, String replacement, boolean missing, boolean substituted) {
}
}
@@ -18,6 +18,8 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.RuntimeUiMessages;
import art.arcane.iris.spi.IrisLogging;
@@ -40,6 +42,8 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference;
@@ -50,6 +54,8 @@ import java.util.concurrent.atomic.AtomicReference;
public final class IrisObjectIO {
private static final String V2_HEADER = "Iris V2 IOB;";
private static final int MAX_PALETTE_ENTRIES = 32_767;
private static final int PALETTE_CACHE_LIMIT = 8192;
private static final Map<String, List<String>> PALETTE_CACHE = new ConcurrentHashMap<>();
private IrisObjectIO() {
}
@@ -86,6 +92,32 @@ public final class IrisObjectIO {
}
}
/**
* {@link #readPaletteKeys(File)} memoized on path, size and mtime. The version-content gate reads the same object
* headers once per placement that lists them, which at boot is the same file dozens of times.
*/
public static List<String> readPaletteKeysCached(File file) {
if (file == null || !file.isFile()) {
return List.of();
}
String key = file.getPath() + '@' + file.lastModified() + '#' + file.length();
List<String> cached = PALETTE_CACHE.get(key);
if (cached != null) {
return cached;
}
List<String> palette = readPaletteKeys(file);
if (PALETTE_CACHE.size() >= PALETTE_CACHE_LIMIT) {
PALETTE_CACHE.clear();
}
PALETTE_CACHE.put(key, palette);
return palette;
}
static IrisBlockVector sampleSize(File file) throws IOException {
try (DataInputStream din = new DataInputStream(new FileInputStream(file))) {
return new IrisBlockVector(din.readInt(), din.readInt(), din.readInt());
@@ -104,7 +136,7 @@ public final class IrisObjectIO {
for (int i = 0; i < s; i++) {
IrisBlockVector pos = new IrisBlockVector(din.readShort(), din.readShort(), din.readShort());
PlatformBlockState data = B.getState(din.readUTF());
PlatformBlockState data = resolvePaletteState(self, din.readUTF());
if (isStructureMarker(data)) {
continue;
}
@@ -148,7 +180,7 @@ public final class IrisObjectIO {
// block count (tens of thousands) instead of times the palette size (hundreds).
PlatformBlockState[] resolved = new PlatformBlockState[palette.size()];
for (i = 0; i < resolved.length; i++) {
resolved[i] = B.getState(palette.get(i));
resolved[i] = resolvePaletteState(self, palette.get(i));
}
s = din.readInt();
@@ -185,6 +217,39 @@ public final class IrisObjectIO {
}
}
/**
* Palette key to state. A key the server does not have goes through the pack's dimension {@code blockFallbacks}
* before the plain lookup, so a declared fallback actually reaches the world instead of becoming air. Present
* keys take exactly one registry lookup, as before.
*/
private static PlatformBlockState resolvePaletteState(IrisObject self, String key) {
PlatformBlockState direct = B.getStateOrNull(key, false);
if (direct != null) {
return direct;
}
IrisData data = self.getLoader();
if (data != null) {
try {
ContentGate gate = data.getContentGate();
if (gate != null && gate.ready()) {
PlatformBlockState viaGate = gate.resolveBlockOrPlaceholder(key);
if (viaGate != null) {
return viaGate;
}
}
} catch (Throwable e) {
// The gate is advisory here; object loading must never fail because of it.
}
}
return B.getState(key);
}
private static boolean isStructureMarker(PlatformBlockState data) {
if (data == null) {
return false;
@@ -63,7 +63,7 @@ public class IrisObjectMarker {
KList<PlatformBlockState> b = new KList<>();
for (IrisBlockData i : mark) {
PlatformBlockState bx = i.getBlockData(rdata);
PlatformBlockState bx = i.getBlockDataOrPlaceholder(rdata);
if (bx != null) {
b.add(bx);
@@ -20,6 +20,12 @@ package art.arcane.iris.engine.object;
import art.arcane.iris.platform.bukkit.BukkitBlockResolution;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatRegistry;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.compat.PackCompatReport;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.data.cache.AtomicCache;
import art.arcane.iris.engine.data.cache.LazyBoundedCache;
@@ -181,6 +187,109 @@ public class IrisObjectPlacement {
@SerializedName(value = "forcePlace", alternate = {"force"})
private boolean forcePlace = false;
private transient AtomicCache<TableCache> cache = new AtomicCache<>();
private transient AtomicCache<CompatStatus> compatCache = new AtomicCache<>();
@Getter(AccessLevel.NONE)
@Setter(AccessLevel.NONE)
private transient volatile KList<String> compatPlaceCache;
/**
* Version-content gate verdict for this placement on the running server. The placement's own generated blocks
* (every {@code edit[].replace} palette option) exclude the whole placement when one of them is unavailable; each
* object in {@code place} is then dropped when its .iob palette carries a key that neither resolves nor is
* rewritten by a matching {@code edit} rule with chance 1. Match sides ({@code edit[].find}, {@code markers[].mark})
* never count. Cached per placement.
*/
public CompatStatus evaluateCompat(IrisData data) {
return compatCache.aquire(() -> computeCompat(data));
}
/** True when nothing in this placement can generate on the running server. */
public boolean isCompatExcluded(IrisData data) {
return evaluateCompat(data).excluded();
}
/** The {@code place} pool with objects that cannot generate on the running server removed. */
public KList<String> compatPlace(IrisData data) {
evaluateCompat(data);
KList<String> survivors = compatPlaceCache;
return survivors == null ? place : survivors;
}
private CompatStatus computeCompat(IrisData data) {
if (data == null) {
compatPlaceCache = place;
return CompatStatus.OK;
}
ContentGate gate = data.getContentGate();
PackCompatReport report = data.getCompatReport();
if (gate == null || !gate.ready()) {
// Registry not bound or not ready: UNKNOWN is never MISSING, so nothing is gated.
report.markIncomplete("registry not ready while evaluating object placements");
compatPlaceCache = place;
return CompatStatus.OK;
}
String subject = compatSubject();
KList<CompatFinding> reasons = new KList<>();
CompatFinding blocked = IrisObjectCompat.evaluateEditPalettes(edit, data, gate, "placement", subject, "", reasons);
IrisObjectCompat.record(report, reasons);
if (blocked != null) {
compatPlaceCache = new KList<>();
return CompatStatus.excludedBy(reasons);
}
KList<String> survivors = new KList<>(place.size());
CompatFinding firstDrop = null;
for (int i = 0; i < place.size(); i++) {
String objectKey = place.get(i);
KList<CompatFinding> objectReasons = new KList<>();
KList<String> missing = IrisObjectCompat.unplaceableKeys(objectKey, edit, data, gate, "object", objectKey,
subject + " place[" + i + "]", objectReasons);
IrisObjectCompat.record(report, objectReasons);
reasons.addAll(objectReasons);
if (missing.isEmpty()) {
survivors.add(objectKey);
continue;
}
// One finding per missing key, so the operator sees every fallback the object needs at once.
for (String key : missing) {
CompatFinding dropped = new CompatFinding(CompatRegistry.BLOCK, key, CompatAction.DROPPED,
"object", objectKey, subject + " place[" + i + "]");
report.record(dropped);
reasons.add(dropped);
if (firstDrop == null) {
firstDrop = dropped;
}
}
}
if (firstDrop != null && survivors.isEmpty()) {
CompatFinding excluded = new CompatFinding(firstDrop.registry(), firstDrop.key(), CompatAction.EXCLUDED,
"placement", subject, "no objects remain");
report.record(excluded);
reasons.add(excluded);
compatPlaceCache = new KList<>();
return CompatStatus.excludedBy(reasons);
}
compatPlaceCache = survivors.size() == place.size() ? place : survivors;
return new CompatStatus(false, reasons);
}
/** The subject a finding names: the single object, or the whole place list when there is more than one. */
private String compatSubject() {
if (place.isEmpty()) {
return "";
}
return place.size() == 1 ? place.getFirst() : place.toString(", ");
}
public IrisObjectPlacement toPlacement(String... place) {
IrisObjectPlacement p = new IrisObjectPlacement();
@@ -244,11 +353,14 @@ public class IrisObjectPlacement {
}
public IrisObject getObject(DataProvider g, RNG random) {
if (place.isEmpty()) {
IrisData data = g.getData();
KList<String> pool = compatPlace(data);
if (pool.isEmpty()) {
return null;
}
return g.getData().getObjectLoader().load(place.get(random.nextInt(place.size())));
return data.getObjectLoader().load(pool.get(random.nextInt(pool.size())));
}
public boolean matches(IrisTreeSize size, TreeType type) {
@@ -298,6 +410,9 @@ public class IrisObjectPlacement {
IrisLogging.warn("Couldn't find loot table " + loot.getName());
continue;
}
if (table.isCompatExcluded()) {
continue;
}
if (loot.getFilter().isEmpty()) //Table applies to all containers
{
@@ -424,7 +424,7 @@ final class IrisObjectPlacementRunner {
IrisMarker marker = self.getLoader().getMarkerLoader().load(j.getMarker());
int markerSalt = markerIndex++;
if (marker == null) {
if (marker == null || marker.isCompatExcluded()) {
continue;
}
@@ -18,6 +18,7 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.data.cache.AtomicCache;
import art.arcane.iris.engine.object.annotations.ArrayType;
@@ -35,6 +36,9 @@ import lombok.NoArgsConstructor;
import art.arcane.iris.spi.PlatformBlockState;
import lombok.experimental.Accessors;
import java.util.LinkedHashMap;
import java.util.Map;
@Snippet("object-block-replacer")
@Accessors(chain = true)
@NoArgsConstructor
@@ -65,7 +69,7 @@ public class IrisObjectReplace {
KList<PlatformBlockState> b = new KList<>();
for (IrisBlockData i : find) {
PlatformBlockState bx = i.getBlockData(rdata);
PlatformBlockState bx = i.getBlockDataOrPlaceholder(rdata);
if (bx != null) {
b.add(bx);
@@ -79,4 +83,89 @@ public class IrisObjectReplace {
public PlatformBlockState getReplace(RNG seed, double x, double y, double z, IrisData rdata) {
return getReplace().get(seed, x, y, z, rdata);
}
/**
* Key-level mirror of the runtime find match, for the version-content gate: it has to decide whether a palette
* key the server does not have is rewritten by this rule, and a missing key has no resolved state to compare.
* Non-exact compares the base block; exact requires every property named by the find entry to match, the same
* partial-match contract {@code PlatformBlockState.matches} uses at runtime.
*/
public boolean matchesState(String stateKey) {
String state = ContentGate.normalizeState(stateKey);
if (state == null) {
return false;
}
String stateBase = ContentGate.baseKey(state);
for (IrisBlockData f : find) {
if (f == null) {
continue;
}
String findState = ContentGate.normalizeState(declaredState(f));
if (findState == null || !ContentGate.baseKey(findState).equals(stateBase)) {
continue;
}
if (!exact || propertiesMatch(findState, state)) {
return true;
}
}
return false;
}
/** The find entry as one state string; {@code block} already carrying properties wins over the data map. */
private static String declaredState(IrisBlockData block) {
String declared = block.getBlock();
if (declared == null) {
return null;
}
return declared.indexOf('[') < 0 ? declared + block.computeProperties() : declared;
}
/** Every property the find side names must be present with the same value on the candidate state. */
private static boolean propertiesMatch(String findState, String candidateState) {
Map<String, String> required = properties(findState);
if (required.isEmpty()) {
return true;
}
Map<String, String> candidate = properties(candidateState);
for (Map.Entry<String, String> entry : required.entrySet()) {
if (!entry.getValue().equals(candidate.get(entry.getKey()))) {
return false;
}
}
return true;
}
private static Map<String, String> properties(String state) {
int open = state.indexOf('[');
int close = state.lastIndexOf(']');
if (open < 0 || close < open) {
return Map.of();
}
Map<String, String> properties = new LinkedHashMap<>();
for (String part : state.substring(open + 1, close).split(",")) {
int split = part.indexOf('=');
if (split > 0) {
properties.put(part.substring(0, split).trim(), part.substring(split + 1).trim());
}
}
return properties;
}
}
@@ -20,6 +20,7 @@ package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.Snippet;
import art.arcane.iris.core.compat.MissingBlockState;
import art.arcane.iris.platform.bukkit.BukkitBlockState;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBlockState;
@@ -287,6 +288,9 @@ public class IrisObjectRotation {
if (state == null) {
return null;
}
if (MissingBlockState.isPlaceholder(state)) {
return state;
}
StateRotator rotator = PLATFORM_ROTATOR;
if (rotator != null) {
@@ -19,6 +19,9 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.engine.framework.render.RenderType;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBlockState;
@@ -44,6 +47,8 @@ import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
@@ -321,6 +326,11 @@ public class IrisRegion extends IrisRegistrant implements IRare {
continue;
}
if (biome.isCompatExcluded()) {
CompatPools.drop(g.getData(), biome, "region", getLoadKey(), "biomes " + i, null);
continue;
}
names.add(biome.getCarvingBiome());
b.put(biome.getLoadKey(), biome);
names.addAll(biome.getChildren());
@@ -345,55 +355,26 @@ public class IrisRegion extends IrisRegistrant implements IRare {
}
public KList<IrisBiome> getRealCaveBiomes(DataProvider g) {
return realCaveBiomes.aquire(() ->
{
KList<IrisBiome> realCaveBiomes = new KList<>();
for (String i : getCaveBiomes()) {
realCaveBiomes.add(g.getData().getBiomeLoader().load(i));
}
return realCaveBiomes;
});
return realCaveBiomes.aquire(() -> resolvePool(g, getCaveBiomes(), "caveBiomes", null));
}
public KList<IrisBiome> getRealShoreBiomes(DataProvider g) {
return realShoreBiomes.aquire(() ->
{
KList<IrisBiome> realShoreBiomes = new KList<>();
for (String i : getShoreBiomes()) {
realShoreBiomes.add(g.getData().getBiomeLoader().load(i));
}
return realShoreBiomes;
});
return realShoreBiomes.aquire(() -> resolvePool(g, getShoreBiomes(), "shoreBiomes", null));
}
public KList<IrisBiome> getRealSeaBiomes(DataProvider g) {
return realSeaBiomes.aquire(() ->
{
KList<IrisBiome> realSeaBiomes = new KList<>();
for (String i : getSeaBiomes()) {
realSeaBiomes.add(g.getData().getBiomeLoader().load(i));
}
return realSeaBiomes;
});
return realSeaBiomes.aquire(() -> resolvePool(g, getSeaBiomes(), "seaBiomes", null));
}
public KList<IrisBiome> getRealLandBiomes(DataProvider g) {
return realLandBiomes.aquire(() ->
{
KList<IrisBiome> realLandBiomes = new KList<>();
return realLandBiomes.aquire(() -> resolvePool(g, getLandBiomes(), "landBiomes", null));
}
for (String i : getLandBiomes()) {
realLandBiomes.add(g.getData().getBiomeLoader().load(i));
}
return realLandBiomes;
});
/** A biome reference pool with unloadable and gate-excluded biomes removed; drops are reported once. */
private KList<IrisBiome> resolvePool(DataProvider g, KList<String> keys, String field, List<CompatFinding> sink) {
IrisData data = g == null ? null : g.getData();
return CompatPools.load(data == null ? null : data.getBiomeLoader(), keys, data,
"region", getLoadKey(), field, sink);
}
public KList<IrisBiome> getAllAnyBiomes() {
@@ -468,6 +449,42 @@ public class IrisRegion extends IrisRegistrant implements IRare {
}
/**
* Cascade: a region that lost every land biome to the gate cannot generate, so it leaves the dimension's region
* pool too. Loading biomes here is safe - biomes never load regions.
*/
@Override
public CompatStatus evaluateCompat(ContentGate gate) {
CompatStatus base = super.evaluateCompat(gate);
if (base.excluded()) {
return base;
}
IrisData data = getLoader();
if (data == null) {
return base;
}
for (int index = 0; index < structures.size(); index++) {
structures.get(index).reportExcludedCaveBiomes(data, "region", getLoadKey(),
"structures[" + index + "].caveBiomes");
}
if (getLandBiomes().isEmpty()) {
return base;
}
List<CompatFinding> drops = new ArrayList<>();
if (!resolvePool(() -> data, getLandBiomes(), "landBiomes", drops).isEmpty()) {
return base;
}
return CompatPools.cascade(data, base, drops, "region", getLoadKey(), "no land biomes remain");
}
@Override
public String getFolderName() {
return "regions";
@@ -4,6 +4,7 @@ import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.annotations.RegistryListResource;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KSet;
@@ -91,6 +92,19 @@ public class IrisRiverPolicy {
@Desc("Rivers that start in this area stay inside it: their whole course and their outlet lie in this region (when set on a region) or this biome (when set on a biome), and a river that flows into the area never leaves it. A source with no reachable outlet inside the area is rejected. Null inherits, false releases the area.")
private Boolean confined = null;
/**
* A declared river biome selection with biomes the version-content gate excluded removed. A declared-but-now-empty
* selection keeps its "explicitly clears the inherited selection" meaning; null (undeclared) stays null.
*/
public KList<String> compatBiomes(KList<String> declared, IrisData data, String field) {
if (declared == null) {
return null;
}
return CompatPools.surviving(data == null ? null : data.getBiomeLoader(), declared, data,
"river policy", "", field);
}
public KSet<String> getAllBiomeIds() {
KSet<String> biomeIds = new KSet<>();
addAll(biomeIds, surfaceBiomes);
@@ -18,6 +18,10 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.annotations.ArrayType;
@@ -30,6 +34,9 @@ import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.util.ArrayList;
import java.util.List;
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
@NoArgsConstructor
@@ -123,6 +130,64 @@ public class IrisSpawner extends IrisRegistrant {
engine.getEngineData().getChunk(x, z).getCooldown(this).spawn(engine);
}
/**
* Cascade: a spawn whose entity the gate excluded can never spawn, so it leaves this spawner's lists; a spawner
* with nothing left to spawn leaves every pool that references it. Loading entities here is safe - entities never
* load spawners.
*/
@Override
public CompatStatus evaluateCompat(ContentGate gate) {
CompatStatus base = super.evaluateCompat(gate);
if (base.excluded()) {
return base;
}
IrisData data = getLoader();
if (data == null || data.getEntityLoader() == null) {
return base;
}
boolean declared = !spawns.isEmpty() || !initialSpawns.isEmpty();
List<CompatFinding> drops = new ArrayList<>();
spawns = dropExcludedSpawns(data, spawns, "spawns", drops);
initialSpawns = dropExcludedSpawns(data, initialSpawns, "initialSpawns", drops);
if (!declared || !spawns.isEmpty() || !initialSpawns.isEmpty()) {
return base;
}
return CompatPools.cascade(data, base, drops, "spawner", getLoadKey(), "no entity spawns remain");
}
private KList<IrisEntitySpawn> dropExcludedSpawns(IrisData data,
KList<IrisEntitySpawn> declared,
String field,
List<CompatFinding> drops) {
KList<IrisEntitySpawn> kept = new KList<>();
for (int index = 0; index < declared.size(); index++) {
IrisEntitySpawn spawn = declared.get(index);
if (spawn == null) {
continue;
}
IrisEntity entity = data.getEntityLoader().load(spawn.getEntity());
if (entity != null && entity.isCompatExcluded()) {
CompatPools.drop(data, entity, "spawner", getLoadKey(),
field + "[" + index + "] " + spawn.getEntity(), drops);
continue;
}
kept.add(spawn);
}
return kept;
}
@Override
public String getFolderName() {
return "spawners";
@@ -1,5 +1,12 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatRegistry;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.compat.PackCompatReport;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber;
@@ -19,6 +26,8 @@ public class IrisStaticObject {
public static final int MAXIMUM_HORIZONTAL_POSITION = 29_999_984;
public static final double MINIMUM_SCALE = 0.01D;
public static final double MAXIMUM_SCALE = 50D;
/** Subject type of every version-content finding a static object records. */
public static final String COMPAT_SUBJECT = "static object";
@Required
@RegistryListResource(IrisObject.class)
@@ -80,6 +89,52 @@ public class IrisStaticObject {
}
}
/**
* Version-content gate verdict for this entry on the running server, mirroring a one-object placement: an
* {@code edit[].replace} palette option the server does not have excludes the entry, and a palette key of the
* object that neither resolves (dimension {@code blockFallbacks} count) nor is rewritten by a matching chance-1
* edit rule drops it. Either way the static layer leaves the entry out and the pack report names it as a static
* object by its object key. Not cached: the dimension compiles its static layer once per snapshot and validation
* evaluates once on a detached loader; the report deduplicates findings.
*
* @param dimensionKey load key of the owning dimension, for the finding detail
* @param index position of this entry in the dimension's {@code staticObjects}
*/
public CompatStatus evaluateCompat(IrisData data, String dimensionKey, int index) {
if (data == null || object == null || object.isBlank()) {
return CompatStatus.OK;
}
ContentGate gate = data.getContentGate();
PackCompatReport report = data.getCompatReport();
if (gate == null || !gate.ready()) {
// Registry not bound or not ready: UNKNOWN is never MISSING, so nothing is gated.
if (report != null) {
report.markIncomplete("registry not ready while evaluating static objects");
}
return CompatStatus.OK;
}
String detail = dimensionKey + " staticObjects[" + index + "]";
KList<CompatFinding> reasons = new KList<>();
boolean excluded = IrisObjectCompat.evaluateEditPalettes(
edit, data, gate, COMPAT_SUBJECT, object, detail + " ", reasons) != null;
if (!excluded) {
// One finding per missing key, so the operator sees every fallback the object needs at once.
for (String key : IrisObjectCompat.unplaceableKeys(
object, edit, data, gate, COMPAT_SUBJECT, object, detail, reasons)) {
reasons.add(new CompatFinding(CompatRegistry.BLOCK, key, CompatAction.DROPPED,
COMPAT_SUBJECT, object, detail));
excluded = true;
}
}
IrisObjectCompat.record(report, reasons);
return excluded ? CompatStatus.excludedBy(reasons) : new CompatStatus(false, reasons);
}
public IrisObjectPlacement toPlacement() {
return new IrisObjectPlacement()
.setPlace(new KList<>(object))
@@ -47,6 +47,10 @@ public final class IrisStaticObjectLayer {
try {
Objects.requireNonNull(entry, "entry must not be null");
entry.validate(dimension.getMinHeight(), dimension.getMaxHeight());
if (entry.evaluateCompat(data, dimension.getLoadKey(), index).excluded()) {
// The pack report already names the entry; the rest of the layer compiles as authored.
continue;
}
IrisObject object = data.getObjectLoader().load(entry.getObject());
if (object == null) {
throw new IllegalArgumentException("Object '" + entry.getObject() + "' could not be loaded");
@@ -18,6 +18,12 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatRegistry;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
@@ -126,6 +132,42 @@ public class IrisStructure extends IrisRegistrant {
return placement;
}
/**
* The start pool is the only mandatory entry point into an assembly: when it has nothing left to place, the
* structure cannot generate on this server and every placement that lists it skips it.
*/
@Override
public CompatStatus evaluateCompat(ContentGate gate) {
CompatStatus base = super.evaluateCompat(gate);
if (base.excluded() || gate == null || !gate.ready()) {
return base;
}
IrisData data = getLoader();
if (data == null || startPool == null || startPool.isBlank()) {
return base;
}
IrisJigsawPool pool = data.load(IrisJigsawPool.class, startPool.trim(), false);
if (pool == null || !pool.isCompatExcluded()) {
return base;
}
CompatFinding reason = pool.getCompat().reasons().isEmpty() ? null : pool.getCompat().reasons().getLast();
CompatFinding excluded = new CompatFinding(
reason == null ? CompatRegistry.BLOCK : reason.registry(),
reason == null ? startPool.trim() : reason.key(),
CompatAction.EXCLUDED, "structure", getLoadKey(),
"start pool " + startPool.trim() + " is unavailable");
gate.report().record(excluded);
KList<CompatFinding> reasons = new KList<>(base.reasons());
reasons.add(excluded);
return CompatStatus.excludedBy(reasons);
}
@Override
public String getFolderName() {
return "structures";
@@ -18,6 +18,7 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber;
@@ -147,4 +148,24 @@ public class IrisStructurePlacement {
IrisStructureAnchorMode activeAnchor = resolvedAnchor();
return activeAnchor == IrisStructureAnchorMode.HEIGHT_BAND || activeAnchor.isCave();
}
/**
* Reports cave biome references the version-content gate excluded. The list is deliberately not filtered: it is an
* allowlist whose emptiness means "any cave biome", and it feeds the placement determinism signature. An excluded
* biome never generates, so the entry is inert - this only makes it visible in the pack compat report.
*/
void reportExcludedCaveBiomes(IrisData data, String subjectType, String subjectKey, String field) {
if (data == null || data.getBiomeLoader() == null || caveBiomes == null) {
return;
}
for (int index = 0; index < caveBiomes.size(); index++) {
String key = caveBiomes.get(index);
IrisBiome biome = data.getBiomeLoader().load(key);
if (biome != null && biome.isCompatExcluded()) {
CompatPools.drop(data, biome, subjectType, subjectKey, field + "[" + index + "] " + key, null);
}
}
}
}
@@ -18,37 +18,20 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.compat.ContentGate;
import art.arcane.iris.util.common.reflect.KeyedType;
import org.bukkit.NamespacedKey;
import org.bukkit.Registry;
import org.bukkit.potion.PotionEffectType;
import java.util.Collections;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
public final class PotionEffectTypes {
private static final Map<String, String> LEGACY_ALIASES;
private static final Set<String> MISSING_WARNED = ConcurrentHashMap.newKeySet();
static {
Map<String, String> m = new HashMap<>();
m.put("SLOW", "SLOWNESS");
m.put("FAST_DIGGING", "HASTE");
m.put("SLOW_DIGGING", "MINING_FATIGUE");
m.put("INCREASE_DAMAGE", "STRENGTH");
m.put("HEAL", "INSTANT_HEALTH");
m.put("HARM", "INSTANT_DAMAGE");
m.put("JUMP", "JUMP_BOOST");
m.put("CONFUSION", "NAUSEA");
m.put("DAMAGE_RESISTANCE", "RESISTANCE");
LEGACY_ALIASES = Collections.unmodifiableMap(m);
}
private PotionEffectTypes() {
}
@@ -60,7 +43,8 @@ public final class PotionEffectTypes {
if (upper.contains(":")) {
upper = upper.substring(upper.indexOf(':') + 1);
}
return LEGACY_ALIASES.getOrDefault(upper, upper);
String path = upper.toLowerCase(Locale.ROOT);
return ContentGate.POTION_EFFECT_ALIASES.getOrDefault(path, path).toUpperCase(Locale.ROOT);
}
public static PotionEffectType resolve(String rawName) {
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Pack-Inhalte anzeigen, die in dieser Minecraft-Version nicht generiert werden können",
"iris.director.commandpack.param.pack_folder_name_compat": "Der Pack-Ordnername für den Bericht",
"iris.bukkit.runtime.commandpack.compat_header": "§ePack '{pack}': Inhalte in Minecraft {version} nicht verfügbar",
"iris.bukkit.runtime.commandpack.compat_none": "§aPack '{pack}' verwendet keine Inhalte, die in Minecraft {version} fehlen.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7Aktualisiere den Server auf ein neueres Minecraft, um diese Inhalte wiederherzustellen, oder deklariere Ersatzwerte (Dimension blockFallbacks, Block backup).",
"iris.modded.moddedpackcommands.compat_header": "Pack '{pack}': Inhalte in Minecraft {version} nicht verfügbar",
"iris.modded.moddedpackcommands.compat_none": "Pack '{pack}' verwendet keine Inhalte, die in Minecraft {version} fehlen.",
"iris.modded.moddedpackcommands.compat_remedy": "Aktualisiere den Server auf ein neueres Minecraft, um diese Inhalte wiederherzustellen, oder deklariere Ersatzwerte (Dimension blockFallbacks, Block backup).",
"iris.modded.help.entry.command.compat_content": "Inhalte anzeigen, die in dieser Minecraft-Version nicht generiert werden können"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Muestra el contenido del pack que no puede generarse en esta versión de Minecraft",
"iris.director.commandpack.param.pack_folder_name_compat": "El nombre de la carpeta del pack sobre el que informar",
"iris.bukkit.runtime.commandpack.compat_header": "§ePack '{pack}': contenido no disponible en Minecraft {version}",
"iris.bukkit.runtime.commandpack.compat_none": "§aEl pack '{pack}' no usa contenido que falte en Minecraft {version}.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7Actualiza el servidor a un Minecraft más reciente para recuperar este contenido, o declara alternativas (blockFallbacks de la dimensión, backup del bloque).",
"iris.modded.moddedpackcommands.compat_header": "Pack '{pack}': contenido no disponible en Minecraft {version}",
"iris.modded.moddedpackcommands.compat_none": "El pack '{pack}' no usa contenido que falte en Minecraft {version}.",
"iris.modded.moddedpackcommands.compat_remedy": "Actualiza el servidor a un Minecraft más reciente para recuperar este contenido, o declara alternativas (blockFallbacks de la dimensión, backup del bloque).",
"iris.modded.help.entry.command.compat_content": "Muestra el contenido que no puede generarse en esta versión de Minecraft"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Näytä paketin sisältö, jota ei voi luoda tässä Minecraft-versiossa",
"iris.director.commandpack.param.pack_folder_name_compat": "Raportoitavan pakettikansion nimi",
"iris.bukkit.runtime.commandpack.compat_header": "§ePaketti '{pack}': sisältö ei ole käytettävissä Minecraft {version} -versiossa",
"iris.bukkit.runtime.commandpack.compat_none": "§aPaketti '{pack}' ei käytä sisältöä, joka puuttuisi Minecraft {version} -versiosta.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7Päivitä palvelin uudempaan Minecraft-versioon palauttaaksesi tämän sisällön tai määritä varasisältö (ulottuvuuden blockFallbacks, lohkon backup).",
"iris.modded.moddedpackcommands.compat_header": "Paketti '{pack}': sisältö ei ole käytettävissä Minecraft {version} -versiossa",
"iris.modded.moddedpackcommands.compat_none": "Paketti '{pack}' ei käytä sisältöä, joka puuttuisi Minecraft {version} -versiosta.",
"iris.modded.moddedpackcommands.compat_remedy": "Päivitä palvelin uudempaan Minecraft-versioon palauttaaksesi tämän sisällön tai määritä varasisältö (ulottuvuuden blockFallbacks, lohkon backup).",
"iris.modded.help.entry.command.compat_content": "Näytä sisältö, jota ei voi luoda tässä Minecraft-versiossa"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Afficher le contenu du pack qui ne peut pas être généré sur cette version de Minecraft",
"iris.director.commandpack.param.pack_folder_name_compat": "Le nom du dossier du pack à analyser",
"iris.bukkit.runtime.commandpack.compat_header": "§ePack '{pack}' : contenu indisponible sur Minecraft {version}",
"iris.bukkit.runtime.commandpack.compat_none": "§aLe pack '{pack}' n'utilise aucun contenu absent de Minecraft {version}.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7Mettez le serveur à jour vers un Minecraft plus récent pour restaurer ce contenu, ou déclarez des replis (blockFallbacks de la dimension, backup du bloc).",
"iris.modded.moddedpackcommands.compat_header": "Pack '{pack}' : contenu indisponible sur Minecraft {version}",
"iris.modded.moddedpackcommands.compat_none": "Le pack '{pack}' n'utilise aucun contenu absent de Minecraft {version}.",
"iris.modded.moddedpackcommands.compat_remedy": "Mettez le serveur à jour vers un Minecraft plus récent pour restaurer ce contenu, ou déclarez des replis (blockFallbacks de la dimension, backup du bloc).",
"iris.modded.help.entry.command.compat_content": "Afficher le contenu qui ne peut pas être généré sur cette version de Minecraft"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "הצג תוכן חבילה שלא ניתן ליצור בגרסת Minecraft הזו",
"iris.director.commandpack.param.pack_folder_name_compat": "שם תיקיית החבילה לדיווח",
"iris.bukkit.runtime.commandpack.compat_header": "§eחבילה '{pack}': תוכן לא זמין ב-Minecraft {version}",
"iris.bukkit.runtime.commandpack.compat_none": "§aהחבילה '{pack}' אינה משתמשת בתוכן שחסר ב-Minecraft {version}.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7עדכן את השרת ל-Minecraft חדש יותר כדי לשחזר את התוכן הזה, או הגדר חלופות (blockFallbacks של הממד, backup של הבלוק).",
"iris.modded.moddedpackcommands.compat_header": "חבילה '{pack}': תוכן לא זמין ב-Minecraft {version}",
"iris.modded.moddedpackcommands.compat_none": "החבילה '{pack}' אינה משתמשת בתוכן שחסר ב-Minecraft {version}.",
"iris.modded.moddedpackcommands.compat_remedy": "עדכן את השרת ל-Minecraft חדש יותר כדי לשחזר את התוכן הזה, או הגדר חלופות (blockFallbacks של הממד, backup של הבלוק).",
"iris.modded.help.entry.command.compat_content": "הצג תוכן שלא ניתן ליצור בגרסת Minecraft הזו"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Mostra i contenuti del pack che non possono essere generati su questa versione di Minecraft",
"iris.director.commandpack.param.pack_folder_name_compat": "Il nome della cartella del pack da analizzare",
"iris.bukkit.runtime.commandpack.compat_header": "§ePack '{pack}': contenuti non disponibili su Minecraft {version}",
"iris.bukkit.runtime.commandpack.compat_none": "§aIl pack '{pack}' non usa contenuti assenti da Minecraft {version}.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7Aggiorna il server a un Minecraft più recente per ripristinare questi contenuti, oppure dichiara delle alternative (blockFallbacks della dimensione, backup del blocco).",
"iris.modded.moddedpackcommands.compat_header": "Pack '{pack}': contenuti non disponibili su Minecraft {version}",
"iris.modded.moddedpackcommands.compat_none": "Il pack '{pack}' non usa contenuti assenti da Minecraft {version}.",
"iris.modded.moddedpackcommands.compat_remedy": "Aggiorna il server a un Minecraft più recente per ripristinare questi contenuti, oppure dichiara delle alternative (blockFallbacks della dimensione, backup del blocco).",
"iris.modded.help.entry.command.compat_content": "Mostra i contenuti che non possono essere generati su questa versione di Minecraft"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "この Minecraft バージョンでは生成できないパック内容を表示します",
"iris.director.commandpack.param.pack_folder_name_compat": "対象とするパックフォルダー名",
"iris.bukkit.runtime.commandpack.compat_header": "§eパック '{pack}': Minecraft {version} では利用できない内容があります",
"iris.bukkit.runtime.commandpack.compat_none": "§aパック '{pack}' に Minecraft {version} で欠けている内容はありません。",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7この内容を復元するにはサーバーを新しい Minecraft に更新するか、代替を宣言してください (ディメンションの blockFallbacks、ブロックの backup)。",
"iris.modded.moddedpackcommands.compat_header": "パック '{pack}': Minecraft {version} では利用できない内容があります",
"iris.modded.moddedpackcommands.compat_none": "パック '{pack}' に Minecraft {version} で欠けている内容はありません。",
"iris.modded.moddedpackcommands.compat_remedy": "この内容を復元するにはサーバーを新しい Minecraft に更新するか、代替を宣言してください (ディメンションの blockFallbacks、ブロックの backup)。",
"iris.modded.help.entry.command.compat_content": "この Minecraft バージョンでは生成できない内容を表示します"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "이 Minecraft 버전에서 생성할 수 없는 팩 콘텐츠를 표시합니다",
"iris.director.commandpack.param.pack_folder_name_compat": "보고할 팩 폴더 이름",
"iris.bukkit.runtime.commandpack.compat_header": "§e팩 '{pack}': Minecraft {version} 에서 사용할 수 없는 콘텐츠",
"iris.bukkit.runtime.commandpack.compat_none": "§a팩 '{pack}' 은 Minecraft {version} 에 없는 콘텐츠를 사용하지 않습니다.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7이 콘텐츠를 복원하려면 서버를 최신 Minecraft 로 업데이트하거나 대체를 선언하세요 (차원의 blockFallbacks, 블록의 backup).",
"iris.modded.moddedpackcommands.compat_header": "팩 '{pack}': Minecraft {version} 에서 사용할 수 없는 콘텐츠",
"iris.modded.moddedpackcommands.compat_none": "팩 '{pack}' 은 Minecraft {version} 에 없는 콘텐츠를 사용하지 않습니다.",
"iris.modded.moddedpackcommands.compat_remedy": "이 콘텐츠를 복원하려면 서버를 최신 Minecraft 로 업데이트하거나 대체를 선언하세요 (차원의 blockFallbacks, 블록의 backup).",
"iris.modded.help.entry.command.compat_content": "이 Minecraft 버전에서 생성할 수 없는 콘텐츠를 표시합니다"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Rodyti paketo turinį, kurio negalima sugeneruoti šioje Minecraft versijoje",
"iris.director.commandpack.param.pack_folder_name_compat": "Paketo aplanko pavadinimas ataskaitai",
"iris.bukkit.runtime.commandpack.compat_header": "§ePaketas '{pack}': turinys nepasiekiamas Minecraft {version}",
"iris.bukkit.runtime.commandpack.compat_none": "§aPaketas '{pack}' nenaudoja turinio, kurio trūksta Minecraft {version}.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7Atnaujinkite serverį į naujesnį Minecraft, kad atkurtumėte šį turinį, arba paskelbkite atsargas (dimensijos blockFallbacks, bloko backup).",
"iris.modded.moddedpackcommands.compat_header": "Paketas '{pack}': turinys nepasiekiamas Minecraft {version}",
"iris.modded.moddedpackcommands.compat_none": "Paketas '{pack}' nenaudoja turinio, kurio trūksta Minecraft {version}.",
"iris.modded.moddedpackcommands.compat_remedy": "Atnaujinkite serverį į naujesnį Minecraft, kad atkurtumėte šį turinį, arba paskelbkite atsargas (dimensijos blockFallbacks, bloko backup).",
"iris.modded.help.entry.command.compat_content": "Rodyti turinį, kurio negalima sugeneruoti šioje Minecraft versijoje"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Toon pack-inhoud die niet kan worden gegenereerd op deze Minecraft-versie",
"iris.director.commandpack.param.pack_folder_name_compat": "De naam van de packmap om te rapporteren",
"iris.bukkit.runtime.commandpack.compat_header": "§ePack '{pack}': inhoud niet beschikbaar op Minecraft {version}",
"iris.bukkit.runtime.commandpack.compat_none": "§aPack '{pack}' gebruikt geen inhoud die ontbreekt in Minecraft {version}.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7Werk de server bij naar een nieuwere Minecraft om deze inhoud te herstellen, of declareer terugvallen (blockFallbacks van de dimensie, backup van het blok).",
"iris.modded.moddedpackcommands.compat_header": "Pack '{pack}': inhoud niet beschikbaar op Minecraft {version}",
"iris.modded.moddedpackcommands.compat_none": "Pack '{pack}' gebruikt geen inhoud die ontbreekt in Minecraft {version}.",
"iris.modded.moddedpackcommands.compat_remedy": "Werk de server bij naar een nieuwere Minecraft om deze inhoud te herstellen, of declareer terugvallen (blockFallbacks van de dimensie, backup van het blok).",
"iris.modded.help.entry.command.compat_content": "Toon inhoud die niet kan worden gegenereerd op deze Minecraft-versie"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Pokaż zawartość paczki, której nie można wygenerować w tej wersji Minecraft",
"iris.director.commandpack.param.pack_folder_name_compat": "Nazwa folderu paczki do raportu",
"iris.bukkit.runtime.commandpack.compat_header": "§ePaczka '{pack}': zawartość niedostępna w Minecraft {version}",
"iris.bukkit.runtime.commandpack.compat_none": "§aPaczka '{pack}' nie używa zawartości brakującej w Minecraft {version}.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7Zaktualizuj serwer do nowszego Minecraft, aby przywrócić tę zawartość, albo zadeklaruj zamienniki (blockFallbacks wymiaru, backup bloku).",
"iris.modded.moddedpackcommands.compat_header": "Paczka '{pack}': zawartość niedostępna w Minecraft {version}",
"iris.modded.moddedpackcommands.compat_none": "Paczka '{pack}' nie używa zawartości brakującej w Minecraft {version}.",
"iris.modded.moddedpackcommands.compat_remedy": "Zaktualizuj serwer do nowszego Minecraft, aby przywrócić tę zawartość, albo zadeklaruj zamienniki (blockFallbacks wymiaru, backup bloku).",
"iris.modded.help.entry.command.compat_content": "Pokaż zawartość, której nie można wygenerować w tej wersji Minecraft"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Mostrar o conteúdo do pack que não pode ser gerado nesta versão do Minecraft",
"iris.director.commandpack.param.pack_folder_name_compat": "O nome da pasta do pack a reportar",
"iris.bukkit.runtime.commandpack.compat_header": "§ePack '{pack}': conteúdo indisponível no Minecraft {version}",
"iris.bukkit.runtime.commandpack.compat_none": "§aO pack '{pack}' não usa conteúdo em falta no Minecraft {version}.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7Atualize o servidor para um Minecraft mais recente para restaurar este conteúdo, ou declare alternativas (blockFallbacks da dimensão, backup do bloco).",
"iris.modded.moddedpackcommands.compat_header": "Pack '{pack}': conteúdo indisponível no Minecraft {version}",
"iris.modded.moddedpackcommands.compat_none": "O pack '{pack}' não usa conteúdo em falta no Minecraft {version}.",
"iris.modded.moddedpackcommands.compat_remedy": "Atualize o servidor para um Minecraft mais recente para restaurar este conteúdo, ou declare alternativas (blockFallbacks da dimensão, backup do bloco).",
"iris.modded.help.entry.command.compat_content": "Mostrar o conteúdo que não pode ser gerado nesta versão do Minecraft"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Показать содержимое пака, которое нельзя сгенерировать в этой версии Minecraft",
"iris.director.commandpack.param.pack_folder_name_compat": "Имя папки пака для отчёта",
"iris.bukkit.runtime.commandpack.compat_header": "§eПак '{pack}': содержимое недоступно в Minecraft {version}",
"iris.bukkit.runtime.commandpack.compat_none": "§aПак '{pack}' не использует содержимое, которого нет в Minecraft {version}.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7Обновите сервер до более новой версии Minecraft, чтобы вернуть это содержимое, или объявите замены (blockFallbacks измерения, backup блока).",
"iris.modded.moddedpackcommands.compat_header": "Пак '{pack}': содержимое недоступно в Minecraft {version}",
"iris.modded.moddedpackcommands.compat_none": "Пак '{pack}' не использует содержимое, которого нет в Minecraft {version}.",
"iris.modded.moddedpackcommands.compat_remedy": "Обновите сервер до более новой версии Minecraft, чтобы вернуть это содержимое, или объявите замены (blockFallbacks измерения, backup блока).",
"iris.modded.help.entry.command.compat_content": "Показать содержимое, которое нельзя сгенерировать в этой версии Minecraft"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Bu Minecraft sürümünde üretilemeyen paket içeriğini göster",
"iris.director.commandpack.param.pack_folder_name_compat": "Raporlanacak paket klasörü adı",
"iris.bukkit.runtime.commandpack.compat_header": "§ePaket '{pack}': içerik Minecraft {version} sürümünde yok",
"iris.bukkit.runtime.commandpack.compat_none": "§aPaket '{pack}' Minecraft {version} sürümünde eksik olan içerik kullanmıyor.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7Bu içeriği geri kazanmak için sunucuyu daha yeni bir Minecraft sürümüne güncelleyin veya yedekler tanımlayın (boyutun blockFallbacks, bloğun backup).",
"iris.modded.moddedpackcommands.compat_header": "Paket '{pack}': içerik Minecraft {version} sürümünde yok",
"iris.modded.moddedpackcommands.compat_none": "Paket '{pack}' Minecraft {version} sürümünde eksik olan içerik kullanmıyor.",
"iris.modded.moddedpackcommands.compat_remedy": "Bu içeriği geri kazanmak için sunucuyu daha yeni bir Minecraft sürümüne güncelleyin veya yedekler tanımlayın (boyutun blockFallbacks, bloğun backup).",
"iris.modded.help.entry.command.compat_content": "Bu Minecraft sürümünde üretilemeyen içeriği göster"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Hiển thị nội dung gói không thể tạo trên phiên bản Minecraft này",
"iris.director.commandpack.param.pack_folder_name_compat": "Tên thư mục gói cần báo cáo",
"iris.bukkit.runtime.commandpack.compat_header": "§eGói '{pack}': nội dung không có trên Minecraft {version}",
"iris.bukkit.runtime.commandpack.compat_none": "§aGói '{pack}' không dùng nội dung nào thiếu trên Minecraft {version}.",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7Cập nhật máy chủ lên Minecraft mới hơn để khôi phục nội dung này, hoặc khai báo phương án dự phòng (blockFallbacks của chiều, backup của khối).",
"iris.modded.moddedpackcommands.compat_header": "Gói '{pack}': nội dung không có trên Minecraft {version}",
"iris.modded.moddedpackcommands.compat_none": "Gói '{pack}' không dùng nội dung nào thiếu trên Minecraft {version}.",
"iris.modded.moddedpackcommands.compat_remedy": "Cập nhật máy chủ lên Minecraft mới hơn để khôi phục nội dung này, hoặc khai báo phương án dự phòng (blockFallbacks của chiều, backup của khối).",
"iris.modded.help.entry.command.compat_content": "Hiển thị nội dung không thể tạo trên phiên bản Minecraft này"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "显示在当前 Minecraft 版本中无法生成的资源包内容",
"iris.director.commandpack.param.pack_folder_name_compat": "要报告的资源包文件夹名称",
"iris.bukkit.runtime.commandpack.compat_header": "§e资源包 '{pack}': 内容在 Minecraft {version} 中不可用",
"iris.bukkit.runtime.commandpack.compat_none": "§a资源包 '{pack}' 未使用 Minecraft {version} 中缺失的内容。",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7将服务器升级到更新的 Minecraft 以恢复这些内容,或声明回退 (维度的 blockFallbacks、方块的 backup)。",
"iris.modded.moddedpackcommands.compat_header": "资源包 '{pack}': 内容在 Minecraft {version} 中不可用",
"iris.modded.moddedpackcommands.compat_none": "资源包 '{pack}' 未使用 Minecraft {version} 中缺失的内容。",
"iris.modded.moddedpackcommands.compat_remedy": "将服务器升级到更新的 Minecraft 以恢复这些内容,或声明回退 (维度的 blockFallbacks、方块的 backup)。",
"iris.modded.help.entry.command.compat_content": "显示在当前 Minecraft 版本中无法生成的内容"
}
}
+10 -1
View File
@@ -1552,6 +1552,15 @@
"iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel",
"iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.",
"iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.",
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display."
"iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.",
"iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "顯示在目前 Minecraft 版本中無法生成的資源包內容",
"iris.director.commandpack.param.pack_folder_name_compat": "要報告的資源包資料夾名稱",
"iris.bukkit.runtime.commandpack.compat_header": "§e資源包 '{pack}': 內容在 Minecraft {version} 中無法使用",
"iris.bukkit.runtime.commandpack.compat_none": "§a資源包 '{pack}' 未使用 Minecraft {version} 中缺少的內容。",
"iris.bukkit.runtime.commandpack.compat_remedy": "§7將伺服器升級到較新的 Minecraft 以還原這些內容,或宣告備援 (維度的 blockFallbacks、方塊的 backup)。",
"iris.modded.moddedpackcommands.compat_header": "資源包 '{pack}': 內容在 Minecraft {version} 中無法使用",
"iris.modded.moddedpackcommands.compat_none": "資源包 '{pack}' 未使用 Minecraft {version} 中缺少的內容。",
"iris.modded.moddedpackcommands.compat_remedy": "將伺服器升級到較新的 Minecraft 以還原這些內容,或宣告備援 (維度的 blockFallbacks、方塊的 backup)。",
"iris.modded.help.entry.command.compat_content": "顯示在目前 Minecraft 版本中無法生成的內容"
}
}
@@ -0,0 +1,410 @@
package art.arcane.iris.core.compat;
import art.arcane.iris.engine.framework.MeteredCache;
import art.arcane.iris.engine.framework.PreservationRegistry;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.LogLevel;
import art.arcane.iris.spi.PlatformBiome;
import art.arcane.iris.spi.PlatformBiomeWriter;
import art.arcane.iris.spi.PlatformBlockProperty;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformEntityType;
import art.arcane.iris.spi.PlatformItem;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.spi.PlatformScheduler;
import art.arcane.iris.spi.PlatformStructureHooks;
import art.arcane.iris.spi.PlatformWorld;
import java.io.File;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
/** Registry fakes for the gate tests: every block is present unless listed as missing; other registries are explicit. */
final class CompatFixtures {
private CompatFixtures() {
}
static FakeRegistries registries(String... missingBlocks) {
FakeRegistries registries = new FakeRegistries();
registries.missingBlocks.addAll(List.of(missingBlocks));
return registries;
}
static FakePlatform bind(FakeRegistries registries) {
IrisPlatforms.unbind();
FakePlatform platform = new FakePlatform(registries);
IrisPlatforms.bind(platform);
IrisServices.register(PreservationRegistry.class, new NoPreservation());
return platform;
}
static void unbind() {
IrisPlatforms.unbind();
IrisServices.remove(PreservationRegistry.class);
}
/** Loaders register their caches for shutdown; nothing to preserve in a test JVM. */
private static final class NoPreservation implements PreservationRegistry {
@Override
public void register(Thread thread) {
}
@Override
public void register(ExecutorService service) {
}
@Override
public void registerCache(MeteredCache cache) {
}
@Override
public void dereference() {
}
}
static final class FakeRegistries implements PlatformRegistries {
final Set<String> missingBlocks = new HashSet<>();
final List<String> blockKeys = new ArrayList<>(List.of("minecraft:stone", "minecraft:air", "minecraft:sand"));
final Set<String> items = new HashSet<>(List.of("minecraft:diamond", "minecraft:stone"));
final Set<String> entities = new HashSet<>(List.of("minecraft:cow", "minecraft:zombie"));
final Set<String> biomes = new HashSet<>(List.of("minecraft:plains", "minecraft:the_void", "minecraft:desert"));
final Set<String> structures = new HashSet<>(List.of("minecraft:village_plains"));
final Set<String> enchantments = new HashSet<>(List.of("minecraft:sharpness"));
final Set<String> potionEffects = new HashSet<>(List.of("minecraft:speed", "minecraft:strength"));
int blockLookups = 0;
@Override
public PlatformBlockState block(String key) {
PlatformBlockState state = blockOrNull(key, false);
return state == null ? air() : state;
}
@Override
public PlatformBlockState blockOrNull(String key) {
return blockOrNull(key, false);
}
@Override
public PlatformBlockState blockOrNull(String key, boolean warn) {
blockLookups++;
String normalized = key.trim().toLowerCase(Locale.ROOT);
int props = normalized.indexOf('[');
String base = props < 0 ? normalized : normalized.substring(0, props);
if (base.indexOf(':') < 0) {
base = "minecraft:" + base;
normalized = base + (props < 0 ? "" : normalized.substring(props));
}
if (missingBlocks.contains(base)) {
return null;
}
return new FakeBlockState(normalized);
}
@Override
public PlatformBlockState air() {
return new FakeBlockState("minecraft:air");
}
@Override
public PlatformBlockState deepSlateOre(PlatformBlockState block, PlatformBlockState ore) {
return ore;
}
@Override
public PlatformBiome biome(String key) {
return null;
}
@Override
public PlatformItem item(String key) {
return null;
}
@Override
public PlatformEntityType entity(String key) {
return null;
}
@Override
public List<String> blockKeys() {
return blockKeys;
}
@Override
public List<String> biomeKeys() {
return new ArrayList<>(biomes);
}
@Override
public List<String> structureKeys() {
return new ArrayList<>(structures);
}
@Override
public List<String> itemKeys() {
return new ArrayList<>(items);
}
@Override
public List<String> entityKeys() {
return new ArrayList<>(entities);
}
@Override
public List<String> blockTypeKeys() {
return blockKeys;
}
@Override
public List<String> enchantmentKeys() {
return new ArrayList<>(enchantments);
}
@Override
public List<String> potionEffectKeys() {
return new ArrayList<>(potionEffects);
}
@Override
public List<String> lootTableKeys() {
return List.of();
}
@Override
public Map<String, List<PlatformBlockProperty>> blockStateProperties() {
return Map.of();
}
}
record FakeBlockState(String key) implements PlatformBlockState {
private static final Map<String, Object> HANDLES = new java.util.concurrent.ConcurrentHashMap<>();
@Override
public String namespace() {
return key.substring(0, key.indexOf(':'));
}
@Override
public boolean isAir() {
return key.equals("minecraft:air");
}
@Override
public boolean isSolid() {
return !isAir();
}
@Override
public boolean isOccluding() {
return false;
}
@Override
public boolean isCustom() {
return false;
}
@Override
public boolean isFluid() {
return false;
}
@Override
public boolean isWater() {
return false;
}
@Override
public boolean isWaterLogged() {
return false;
}
@Override
public boolean isLit() {
return false;
}
@Override
public boolean isUpdatable() {
return false;
}
@Override
public boolean isFoliage() {
return false;
}
@Override
public boolean isTreeBlock() {
return false;
}
@Override
public boolean isFoliagePlantable() {
return false;
}
@Override
public boolean isDecorant() {
return false;
}
@Override
public boolean isStorage() {
return false;
}
@Override
public boolean isStorageChest() {
return false;
}
@Override
public boolean isOre() {
return false;
}
@Override
public boolean isDeepSlate() {
return false;
}
@Override
public boolean isVineBlock() {
return false;
}
@Override
public boolean canPlaceOnto(PlatformBlockState onto) {
return true;
}
@Override
public boolean matches(PlatformBlockState state) {
return state != null && key.equals(state.key());
}
@Override
public boolean hasTileEntity() {
return false;
}
@Override
public PlatformBlockState withProperty(String name, String value) {
return this;
}
/** One mocked {@code BlockData} per key: the Bukkit rotation path casts the handle before it inspects it. */
@Override
public Object nativeHandle() {
return HANDLES.computeIfAbsent(key, ignored -> org.mockito.Mockito.mock(org.bukkit.block.data.BlockData.class));
}
}
static final class FakePlatform implements IrisPlatform {
private final PlatformRegistries registries;
private final File dataFolder;
FakePlatform(PlatformRegistries registries) {
this.registries = registries;
File folder;
try {
folder = java.nio.file.Files.createTempDirectory("iris-compat-platform").toFile();
} catch (java.io.IOException e) {
folder = new File(System.getProperty("java.io.tmpdir"), "iris-compat-platform");
}
this.dataFolder = folder;
}
@Override
public String platformName() {
return "fake";
}
@Override
public String minecraftVersion() {
return "26.1.2";
}
@Override
public PlatformRegistries registries() {
return registries;
}
@Override
public PlatformScheduler scheduler() {
return null;
}
@Override
public PlatformStructureHooks structureHooks() {
return null;
}
@Override
public PlatformBiomeWriter biomeWriter() {
return null;
}
@Override
public File dataFolder() {
return dataFolder;
}
@Override
public File dataFile(String... path) {
return new File(dataFolder, String.join(File.separator, path));
}
@Override
public File pluginJar() {
return new File(".");
}
@Override
public int irisVersionNumber() {
return 0;
}
@Override
public int minecraftVersionNumber() {
return 0;
}
@Override
public void callEvent(Object event) {
}
@Override
public void dispatchConsoleCommand(String command) {
}
@Override
public boolean spawnEntity(PlatformWorld world, String entityKey, double x, double y, double z) {
return false;
}
@Override
public void log(LogLevel level, String message) {
if (level == LogLevel.WARN || level == LogLevel.ERROR) {
System.err.println("[compat-test " + level + "] " + message);
}
}
@Override
public void msg(String message) {
}
@Override
public void reportError(Throwable error) {
error.printStackTrace(System.err);
}
}
}
@@ -0,0 +1,256 @@
package art.arcane.iris.core.compat;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IObjectPlacer;
import art.arcane.iris.engine.object.IrisBlockData;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.engine.object.IrisObjectReplace;
import art.arcane.iris.engine.object.ObjectPlaceMode;
import art.arcane.iris.engine.object.TileData;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public class ContentGatePlaceholderTest {
private static CompatFixtures.FakeRegistries registries;
@BeforeClass
public static void bindPlatform() {
registries = CompatFixtures.registries("minecraft:sulfur", "minecraft:brimstone");
CompatFixtures.bind(registries);
}
@AfterClass
public static void unbindPlatform() {
CompatFixtures.unbind();
}
private static ContentGate gate() {
return new ContentGate(registries, Map.of(), new PackCompatReport());
}
@Test
public void placeholderOnlyForMissingKeysAndOnlyWhenReady() {
ContentGate gate = gate();
PlatformBlockState present = gate.resolveBlockOrPlaceholder("STONE");
assertFalse(MissingBlockState.isPlaceholder(present));
assertEquals("minecraft:stone", present.key());
PlatformBlockState missing = gate.resolveBlockOrPlaceholder("minecraft:sulfur[lit=true]");
assertTrue(MissingBlockState.isPlaceholder(missing));
assertEquals("minecraft:sulfur[lit=true]", missing.key());
assertEquals("minecraft:sulfur", missing.materialKey());
assertEquals("minecraft", missing.namespace());
assertSame(missing, gate.resolveBlockOrPlaceholder("minecraft:sulfur[lit=true]"));
assertNull(gate.resolveBlockOrPlaceholder(" "));
ContentGate unbound = new ContentGate(null, Map.of(), new PackCompatReport());
assertNull(unbound.resolveBlockOrPlaceholder("minecraft:sulfur"));
}
@Test
public void placeholderBehavesAsAirButKeepsItsKey() {
PlatformBlockState air = registries.air();
MissingBlockState placeholder = MissingBlockState.of("minecraft:sulfur[lit=true,axis=y]", air);
assertTrue(placeholder.isAir());
assertFalse(placeholder.isSolid());
assertFalse(placeholder.isCustom());
assertFalse(placeholder.hasTileEntity());
assertEquals(air.nativeHandle(), placeholder.nativeHandle());
assertEquals("minecraft:sulfur", placeholder.missingKey());
MissingBlockState exact = MissingBlockState.of("minecraft:sulfur[lit=true]", air);
MissingBlockState other = MissingBlockState.of("minecraft:brimstone", air);
assertTrue(exact.matches(placeholder));
assertFalse(placeholder.matches(exact));
assertFalse(exact.matches(other));
assertFalse(exact.matches(air));
assertFalse(air.matches(exact));
assertEquals(MissingBlockState.of("minecraft:sulfur[lit=true]", air), exact);
assertEquals(exact.hashCode(), MissingBlockState.of("minecraft:sulfur[lit=true]", air).hashCode());
assertEquals("minecraft:sulfur[lit=true,axis=y]", exact.withProperty("axis", "y").key());
}
@Test
public void blockDataPlaceholderFollowsTheChainBeforeGivingUp() throws Exception {
IrisData data = IrisData.get(Files.createTempDirectory("iris-compat-placeholder").toFile());
PlatformBlockState present = new IrisBlockData("minecraft:stone").getBlockDataOrPlaceholder(data);
assertFalse(MissingBlockState.isPlaceholder(present));
assertEquals("minecraft:stone", present.key());
PlatformBlockState revived = new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:sand")).getBlockDataOrPlaceholder(data);
assertEquals("minecraft:sand", revived.key());
assertFalse(MissingBlockState.isPlaceholder(revived));
IrisBlockData missing = new IrisBlockData("minecraft:sulfur");
missing.getData().put("lit", true);
PlatformBlockState placeholder = missing.getBlockDataOrPlaceholder(data);
assertTrue(MissingBlockState.isPlaceholder(placeholder));
assertEquals("minecraft:sulfur[lit=true]", placeholder.key());
assertTrue(missing.getBlockData(data).isAir());
assertFalse(MissingBlockState.isPlaceholder(missing.getBlockData(data)));
}
@Test
public void fullChanceEditRuleRewritesAMissingPaletteKeyDuringPlacement() throws Exception {
File pack = Files.createTempDirectory("iris-compat-rescue").toFile();
writeObject(pack, "rescue", "minecraft:sulfur[lit=true]");
IrisData data = IrisData.get(pack);
IrisObject object = data.getObjectLoader().load("rescue");
assertNotNull(object);
PlatformBlockState loaded = object.getBlocks().values().iterator().next();
assertTrue(loaded.toString(), MissingBlockState.isPlaceholder(loaded));
IrisObjectPlacement placement = placement();
placement.getEdit().add(replace(1F, false, "minecraft:sulfur", "minecraft:stone"));
RecordingPlacer placer = new RecordingPlacer();
object.place(0, 64, 0, placer, placement, new RNG(7L), data);
assertEquals(1, placer.writes.size());
assertEquals("minecraft:stone", placer.writes.getFirst().key());
IrisObjectPlacement exact = placement();
exact.getEdit().add(replace(1F, true, "minecraft:sulfur[lit=true]", "minecraft:sand"));
RecordingPlacer exactPlacer = new RecordingPlacer();
object.place(0, 64, 0, exactPlacer, exact, new RNG(7L), data);
assertEquals("minecraft:sand", exactPlacer.writes.getFirst().key());
IrisObjectPlacement mismatch = placement();
mismatch.getEdit().add(replace(1F, true, "minecraft:sulfur[lit=false]", "minecraft:sand"));
RecordingPlacer mismatchPlacer = new RecordingPlacer();
object.place(0, 64, 0, mismatchPlacer, mismatch, new RNG(7L), data);
PlatformBlockState untouched = mismatchPlacer.writes.getFirst();
assertTrue(MissingBlockState.isPlaceholder(untouched));
assertTrue(untouched.isAir());
assertEquals(registries.air().nativeHandle(), untouched.nativeHandle());
}
private static IrisObjectPlacement placement() {
IrisObjectPlacement placement = new IrisObjectPlacement();
placement.setPlace(new KList<>(List.of("rescue")));
placement.setMode(ObjectPlaceMode.CENTER_HEIGHT);
placement.setRequireSurfaceSupport(false);
return placement;
}
private static IrisObjectReplace replace(float chance, boolean exact, String find, String to) {
IrisObjectReplace rule = new IrisObjectReplace();
rule.setChance(chance);
rule.setExact(exact);
rule.getFind().add(IrisBlockData.from(find));
rule.getReplace().qclear();
rule.getReplace().add(to);
return rule;
}
/** A one-block V2 .iob whose only palette entry is {@code state}. */
private static void writeObject(File pack, String key, String state) throws Exception {
File file = new File(pack, "objects/" + key + ".iob");
assertTrue(file.getParentFile().mkdirs());
try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) {
out.writeInt(1);
out.writeInt(1);
out.writeInt(1);
out.writeUTF("Iris V2 IOB;");
out.writeShort(1);
out.writeUTF(state);
out.writeInt(1);
out.writeShort(0);
out.writeShort(0);
out.writeShort(0);
out.writeShort(0);
out.writeInt(0);
}
}
private static final class RecordingPlacer implements IObjectPlacer {
private final List<PlatformBlockState> writes = new ArrayList<>();
@Override
public int getHighest(int x, int z, IrisData data) {
return 64;
}
@Override
public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) {
return 64;
}
@Override
public void set(int x, int y, int z, PlatformBlockState d) {
writes.add(d);
}
@Override
public PlatformBlockState get(int x, int y, int z) {
return null;
}
@Override
public boolean isPreventingDecay() {
return false;
}
@Override
public boolean isCarved(int x, int y, int z) {
return false;
}
@Override
public boolean isSolid(int x, int y, int z) {
return false;
}
@Override
public boolean isUnderwater(int x, int z) {
return false;
}
@Override
public int getFluidHeight() {
return 0;
}
@Override
public boolean isDebugSmartBore() {
return false;
}
@Override
public void setTile(int xx, int yy, int zz, TileData tile) {
}
@Override
public <T> void setData(int xx, int yy, int zz, T data) {
}
@Override
public <T> T getData(int xx, int yy, int zz, Class<T> t) {
return null;
}
@Override
public Engine getEngine() {
return null;
}
}
}
@@ -0,0 +1,190 @@
package art.arcane.iris.core.compat;
import art.arcane.iris.core.compat.ContentGate.BlockResolution;
import art.arcane.iris.core.compat.ContentGate.BlockResolution.Source;
import org.junit.Test;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public class ContentGateTest {
private static ContentGate gate(CompatFixtures.FakeRegistries registries, Map<String, String> fallbacks) {
return new ContentGate(registries, fallbacks, new PackCompatReport());
}
@Test
public void presentBlockResolvesFromRegistry() {
ContentGate gate = gate(CompatFixtures.registries(), Map.of());
BlockResolution resolution = gate.resolveBlock("STONE");
assertNotNull(resolution);
assertEquals("minecraft:stone", resolution.resolvedKey());
assertEquals("minecraft:stone", resolution.state().key());
assertFalse(resolution.substituted());
assertNull(resolution.substitutedFrom());
assertEquals(Source.REGISTRY, resolution.source());
assertEquals(KeyStatus.PRESENT, gate.block("minecraft:stone[axis=y]"));
}
@Test
public void missingBlockResolvesToNull() {
ContentGate gate = gate(CompatFixtures.registries("minecraft:sulfur"), Map.of());
assertNull(gate.resolveBlock("minecraft:sulfur"));
assertNull(gate.resolveBlock("sulfur[lit=true]"));
assertEquals(KeyStatus.MISSING, gate.block("minecraft:sulfur"));
}
@Test
public void unknownWhenRegistriesAbsentOrEmpty() {
ContentGate unbound = gate(null, Map.of());
assertFalse(unbound.ready());
assertNull(unbound.resolveBlock("minecraft:stone"));
assertEquals(KeyStatus.UNKNOWN, unbound.block("minecraft:stone"));
assertEquals(KeyStatus.UNKNOWN, unbound.entity("minecraft:cow"));
CompatFixtures.FakeRegistries empty = CompatFixtures.registries();
empty.blockKeys.clear();
ContentGate notReady = gate(empty, Map.of());
assertFalse(notReady.ready());
assertEquals(KeyStatus.UNKNOWN, notReady.block("minecraft:stone"));
assertEquals(KeyStatus.UNKNOWN, notReady.item("minecraft:diamond"));
}
@Test
public void perRegistryUnknownWhenThatKeyListIsEmpty() {
CompatFixtures.FakeRegistries registries = CompatFixtures.registries();
registries.biomes.clear();
registries.structures.clear();
ContentGate gate = gate(registries, Map.of());
assertTrue(gate.ready());
assertEquals(KeyStatus.UNKNOWN, gate.biome("minecraft:plains"));
assertEquals(KeyStatus.UNKNOWN, gate.structure("minecraft:village_plains"));
assertEquals(KeyStatus.PRESENT, gate.entity("COW"));
assertEquals(KeyStatus.MISSING, gate.entity("minecraft:camel"));
}
@Test
public void nonBlockRegistriesNormalizeKeys() {
ContentGate gate = gate(CompatFixtures.registries(), Map.of());
assertEquals(KeyStatus.PRESENT, gate.item("DIAMOND"));
assertEquals(KeyStatus.MISSING, gate.item("minecraft:netherite_upgrade_template"));
assertEquals(KeyStatus.PRESENT, gate.biome("minecraft:plains"));
assertEquals(KeyStatus.MISSING, gate.biome("minecraft:pale_garden"));
assertEquals(KeyStatus.PRESENT, gate.enchantment("sharpness"));
assertEquals(KeyStatus.MISSING, gate.enchantment("minecraft:wind_burst"));
assertEquals(KeyStatus.PRESENT, gate.potionEffect("SPEED"));
assertEquals(KeyStatus.PRESENT, gate.potionEffect("INCREASE_DAMAGE"));
assertEquals(KeyStatus.MISSING, gate.potionEffect("minecraft:infested"));
assertEquals(KeyStatus.MISSING, gate.item(" "));
}
@Test
public void dimensionFallbackSubstitutesAndIsReported() {
ContentGate gate = gate(CompatFixtures.registries("minecraft:sulfur"), Map.of("minecraft:sulfur", "minecraft:stone"));
BlockResolution resolution = gate.resolveBlock("minecraft:sulfur[lit=true]");
assertNotNull(resolution);
assertTrue(resolution.substituted());
assertEquals(Source.FALLBACK, resolution.source());
assertEquals("minecraft:stone", resolution.resolvedKey());
assertEquals("minecraft:sulfur[lit=true]", resolution.substitutedFrom());
assertEquals("minecraft:stone", gate.fallbacks().get("minecraft:sulfur"));
}
@Test
public void fallbackThatIsItselfMissingCountsAsMissing() {
ContentGate gate = gate(CompatFixtures.registries("minecraft:sulfur", "minecraft:brimstone"),
Map.of("minecraft:sulfur", "minecraft:brimstone"));
assertNull(gate.resolveBlock("minecraft:sulfur"));
}
@Test
public void legacyRenameHitIsSubstitutedButNotReported() {
ContentGate gate = gate(CompatFixtures.registries("minecraft:grass"), Map.of());
BlockResolution resolution = gate.resolveBlock("minecraft:grass");
assertNotNull(resolution);
assertEquals("minecraft:short_grass", resolution.resolvedKey());
assertEquals(Source.RENAME, resolution.source());
assertFalse(resolution.substituted());
assertNull(resolution.substitutedFrom());
assertTrue(gate.report().isEmpty());
}
@Test
public void legacyRenameFollowsChainsAndGivesUpOnCycles() {
ContentGate chained = gate(CompatFixtures.registries("minecraft:soul_soil", "minecraft:soulsand"), Map.of());
assertNull(chained.resolveBlock("minecraft:soul_soil"));
ContentGate cyclic = gate(CompatFixtures.registries("minecraft:grass", "minecraft:short_grass"), Map.of());
assertNull(cyclic.resolveBlock("minecraft:grass"));
}
@Test
public void legacyRenameExactEntriesMatchFullState() {
ContentGate gate = gate(CompatFixtures.registries("minecraft:barrel"), Map.of());
BlockResolution resolution = gate.resolveBlock("minecraft:barrel[facing=east]");
assertNotNull(resolution);
assertEquals("minecraft:hay_bale[axis=x]", resolution.resolvedKey());
assertEquals(Source.RENAME, resolution.source());
}
@Test
public void renameRunsBeforeFallback() {
ContentGate gate = gate(CompatFixtures.registries("minecraft:grass"), Map.of("minecraft:grass", "minecraft:sand"));
BlockResolution resolution = gate.resolveBlock("minecraft:grass");
assertNotNull(resolution);
assertEquals(Source.RENAME, resolution.source());
assertEquals("minecraft:short_grass", resolution.resolvedKey());
}
@Test
public void resolutionsAreCachedPerNormalizedState() {
CompatFixtures.FakeRegistries registries = CompatFixtures.registries("minecraft:sulfur");
ContentGate gate = gate(registries, Map.of());
BlockResolution first = gate.resolveBlock("minecraft:stone");
int after = registries.blockLookups;
BlockResolution second = gate.resolveBlock("STONE");
assertSame(first, second);
assertEquals(after, registries.blockLookups);
assertNull(gate.resolveBlock("minecraft:sulfur"));
int misses = registries.blockLookups;
assertNull(gate.resolveBlock("minecraft:sulfur"));
assertEquals(misses, registries.blockLookups);
gate.invalidate();
gate.resolveBlock("minecraft:stone");
assertTrue(registries.blockLookups > after);
}
@Test
public void normalizeStateAndBaseKey() {
assertEquals("minecraft:stone", ContentGate.normalizeState(" STONE "));
assertEquals("minecraft:oak_log[axis=y]", ContentGate.normalizeState("Oak_Log[AXIS=Y]"));
assertEquals("create:cogwheel", ContentGate.normalizeState("create:cogwheel"));
assertNull(ContentGate.normalizeState(null));
assertNull(ContentGate.normalizeState(" "));
assertEquals("minecraft:oak_log", ContentGate.baseKey("minecraft:oak_log[axis=y]"));
assertEquals("minecraft:oak_log", ContentGate.baseKey("minecraft:oak_log"));
assertNull(ContentGate.baseKey(null));
}
@Test
public void readBlockFallbacksMergesDimensionFiles() throws Exception {
java.io.File pack = java.nio.file.Files.createTempDirectory("iris-compat-fallbacks").toFile();
java.io.File dimensions = new java.io.File(pack, "dimensions");
assertTrue(dimensions.mkdirs());
java.nio.file.Files.writeString(new java.io.File(dimensions, "overworld.json").toPath(),
"{\"name\":\"x\",\"blockFallbacks\":{\"minecraft:sulfur\":\"minecraft:stone\"}}");
java.nio.file.Files.writeString(new java.io.File(dimensions, "other.json").toPath(),
"{\"name\":\"y\",\"blockFallbacks\":{\"minecraft:sulfur\":\"minecraft:sand\",\"minecraft:x\":\"minecraft:dirt\"}}");
Map<String, String> read = ContentGate.readBlockFallbacks(pack);
assertEquals(2, read.size());
assertTrue(List.of("minecraft:stone", "minecraft:sand").contains(read.get("minecraft:sulfur")));
assertEquals("minecraft:dirt", read.get("minecraft:x"));
assertTrue(ContentGate.readBlockFallbacks(new java.io.File(pack, "nope")).isEmpty());
}
}
@@ -0,0 +1,576 @@
package art.arcane.iris.core.compat;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisBiomeCustom;
import art.arcane.iris.engine.object.IrisBiomeCustomSpawn;
import art.arcane.iris.engine.object.IrisBiomePaletteLayer;
import art.arcane.iris.engine.object.IrisBlockData;
import art.arcane.iris.engine.object.IrisDecorator;
import art.arcane.iris.engine.object.IrisEffect;
import art.arcane.iris.engine.object.IrisEnchantment;
import art.arcane.iris.engine.object.IrisEntity;
import art.arcane.iris.engine.object.IrisLoot;
import art.arcane.iris.engine.object.IrisLootTable;
import art.arcane.iris.engine.object.IrisMaterialPalette;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.engine.object.IrisObjectReplace;
import art.arcane.iris.engine.object.annotations.RegistryListBlockType;
import art.arcane.iris.engine.object.annotations.RegistryListItemType;
import art.arcane.volmlib.util.collection.KList;
import org.junit.Test;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class ContentGateWalkerTest {
private static ContentGate gate(String... missingBlocks) {
return new ContentGate(CompatFixtures.registries(missingBlocks), Map.of(), new PackCompatReport());
}
private static ContentGate gate(CompatFixtures.FakeRegistries registries) {
return new ContentGate(registries, Map.of(), new PackCompatReport());
}
private static IrisBiome biome(String key) {
IrisBiome biome = new IrisBiome();
biome.setLoadKey(key);
return biome;
}
private static IrisBiomePaletteLayer layer(String... blocks) {
KList<IrisBlockData> palette = new KList<>();
for (String block : blocks) {
palette.add(new IrisBlockData(block));
}
return new IrisBiomePaletteLayer().setPalette(palette);
}
private static IrisLoot loot(String item) {
return new IrisLoot().setType(item);
}
/** Reads a field directly; several registrant getters resolve through Bukkit registries that are absent here. */
private static Object rawField(Object owner, String name) throws Exception {
java.lang.reflect.Field field = owner.getClass().getDeclaredField(name);
field.setAccessible(true);
return field.get(owner);
}
private static CompatFinding only(CompatStatus status) {
assertEquals(status.reasons().toString(), 1, status.reasons().size());
return status.reasons().getFirst();
}
private static void assertFinding(CompatFinding finding, CompatRegistry registry, String key, CompatAction action,
String subjectType, String subjectKey, String detail) {
assertEquals(registry, finding.registry());
assertEquals(key, finding.key());
assertEquals(action, finding.action());
assertEquals(subjectType, finding.subjectType());
assertEquals(subjectKey, finding.subjectKey());
assertEquals(detail, finding.detail());
}
public static final class Probe extends IrisRegistrant {
@RegistryListBlockType
private String block = "minecraft:stone";
@RegistryListItemType
private KList<String> items = new KList<>();
private KList<IrisObjectReplace> edit = new KList<>();
private IrisObjectPlacement placement = null;
private IrisLoot gear = null;
private Probe self = null;
private KList<Probe> children = new KList<>();
private KList<IrisDecorator> decorators = new KList<>();
@Override
public String getFolderName() {
return "probes";
}
@Override
public String getTypeName() {
return "Probe";
}
void setBlock(String block) {
this.block = block;
}
KList<String> getItems() {
return items;
}
void setItems(KList<String> items) {
this.items = items;
}
void setEdit(KList<IrisObjectReplace> edit) {
this.edit = edit;
}
void setPlacement(IrisObjectPlacement placement) {
this.placement = placement;
}
IrisLoot getGear() {
return gear;
}
void setGear(IrisLoot gear) {
this.gear = gear;
}
void setSelf(Probe self) {
this.self = self;
}
void setChildren(KList<Probe> children) {
this.children = children;
}
void setDecorators(KList<IrisDecorator> decorators) {
this.decorators = decorators;
}
}
@Test
public void missingLayerBlockExcludesBiomeWithFieldPath() {
ContentGate gate = gate("minecraft:sulfur");
IrisBiome biome = biome("cave/sulfur-grotto");
biome.setLayers(new KList<>(List.of(layer("minecraft:stone"), layer("minecraft:stone", "minecraft:dirt", "minecraft:sulfur"))));
CompatStatus status = gate.evaluate(biome);
assertTrue(status.excluded());
assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "biome", "cave/sulfur-grotto", "layers[1].palette[2]");
assertEquals(1, gate.report().size());
assertEquals(status.reasons().getFirst(), gate.report().findings().getFirst());
}
@Test
public void blockPropertiesTravelWithTheKey() {
ContentGate gate = gate("minecraft:sulfur");
IrisBiome biome = biome("b");
IrisBlockData entry = new IrisBlockData("sulfur");
entry.getData().put("lit", true);
biome.setLayers(new KList<>(List.of(new IrisBiomePaletteLayer().setPalette(new KList<>(List.of(entry))))));
CompatStatus status = gate.evaluate(biome);
assertTrue(status.excluded());
assertEquals("minecraft:sulfur", only(status).key());
}
@Test
public void backupRevivesEntryAsSubstitution() {
ContentGate gate = gate("minecraft:sulfur");
IrisBiome biome = biome("desert/dunes");
IrisBlockData entry = new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:sand"));
biome.setLayers(new KList<>(List.of(new IrisBiomePaletteLayer().setPalette(new KList<>(List.of(entry))))));
CompatStatus status = gate.evaluate(biome);
assertFalse(status.excluded());
assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.SUBSTITUTED, "biome", "desert/dunes", "layers[0].palette[0] (backup minecraft:sand)");
assertEquals(1, gate.report().size());
}
@Test
public void backupChainsAndMissingBackupStillExcludes() {
ContentGate gate = gate("minecraft:sulfur", "minecraft:brimstone");
IrisBiome chained = biome("chained");
IrisBlockData deep = new IrisBlockData("minecraft:sulfur")
.setBackup(new IrisBlockData("minecraft:brimstone").setBackup(new IrisBlockData("minecraft:sand")));
chained.setLayers(new KList<>(List.of(new IrisBiomePaletteLayer().setPalette(new KList<>(List.of(deep))))));
CompatStatus revived = gate.evaluate(chained);
assertFalse(revived.excluded());
assertEquals("layers[0].palette[0] (backup minecraft:sand)", only(revived).detail());
IrisBiome dead = biome("dead");
IrisBlockData deadEntry = new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:brimstone"));
dead.setLayers(new KList<>(List.of(new IrisBiomePaletteLayer().setPalette(new KList<>(List.of(deadEntry))))));
CompatStatus excluded = gate.evaluate(dead);
assertTrue(excluded.excluded());
assertFinding(only(excluded), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "biome", "dead", "layers[0].palette[0]");
}
@Test
public void dimensionFallbackSubstitutesEntry() {
ContentGate gate = new ContentGate(CompatFixtures.registries("minecraft:sulfur"), Map.of("minecraft:sulfur", "minecraft:stone"), new PackCompatReport());
IrisBiome biome = biome("desert/flats");
biome.setLayers(new KList<>(List.of(layer("minecraft:sulfur"))));
CompatStatus status = gate.evaluate(biome);
assertFalse(status.excluded());
assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.SUBSTITUTED, "biome", "desert/flats", "layers[0].palette[0] (fallback minecraft:stone)");
}
@Test
public void legacyRenameHitIsNeitherExcludedNorReported() {
ContentGate gate = gate("minecraft:grass");
IrisBiome biome = biome("plains");
biome.setLayers(new KList<>(List.of(layer("minecraft:grass"))));
CompatStatus status = gate.evaluate(biome);
assertFalse(status.excluded());
assertTrue(status.reasons().isEmpty());
assertTrue(gate.report().isEmpty());
}
@Test
public void missingEntityTypeExcludesEntity() {
ContentGate gate = gate();
IrisEntity entity = new IrisEntity().setType("minecraft:camel");
entity.setLoadKey("camel");
CompatStatus status = gate.evaluate(entity);
assertTrue(status.excluded());
assertFinding(only(status), CompatRegistry.ENTITY, "minecraft:camel", CompatAction.EXCLUDED, "entity", "camel", "type");
assertEquals("minecraft:camel", entity.getType());
}
@Test
public void inlinePassengerWithMissingTypeIsDroppedFromItsList() {
ContentGate gate = gate();
IrisEntity entity = new IrisEntity().setType("minecraft:zombie");
entity.setLoadKey("rider");
entity.setPassengers(new KList<>(List.of(new IrisEntity().setType("minecraft:cow"), new IrisEntity().setType("minecraft:camel"))));
CompatStatus status = gate.evaluate(entity);
assertFalse(status.excluded());
assertEquals(1, entity.getPassengers().size());
assertEquals("minecraft:cow", entity.getPassengers().getFirst().getType());
assertFinding(only(status), CompatRegistry.ENTITY, "minecraft:camel", CompatAction.DROPPED, "entity", "rider", "passengers[1].type");
}
@Test
public void customBiomeSpawnIsDroppedBeforeDatapackWrite() {
ContentGate gate = gate();
IrisBiome biome = biome("custom/lush");
IrisBiomeCustom custom = new IrisBiomeCustom().setId("lush");
custom.setSpawns(new KList<>(List.of(new IrisBiomeCustomSpawn().setType("minecraft:cow"), new IrisBiomeCustomSpawn().setType("camel"))));
biome.setCustomDerivitives(new KList<>(List.of(custom)));
CompatStatus status = gate.evaluate(biome);
assertFalse(status.excluded());
assertEquals(1, custom.getSpawns().size());
assertEquals("minecraft:cow", custom.getSpawns().getFirst().getTypeKey());
assertFinding(only(status), CompatRegistry.ENTITY, "minecraft:camel", CompatAction.DROPPED, "biome", "custom/lush", "customDerivitives[0].spawns[1].type");
}
@Test
public void lootItemIsDroppedFromTable() {
ContentGate gate = gate();
IrisLootTable table = new IrisLootTable();
table.setLoadKey("chests/common");
table.setLoot(new KList<>(List.of(loot("minecraft:diamond"), loot("minecraft:mace"))));
CompatStatus status = gate.evaluate(table);
assertFalse(status.excluded());
assertEquals(1, table.getLoot().size());
assertEquals("minecraft:diamond", table.getLoot().getFirst().getTypeKey());
assertFinding(only(status), CompatRegistry.ITEM, "minecraft:mace", CompatAction.DROPPED, "loot", "chests/common", "loot[1].type");
}
@Test
public void missingEnchantmentIsDroppedButLootEntryStays() {
ContentGate gate = gate();
IrisLootTable table = new IrisLootTable();
table.setLoadKey("chests/rare");
IrisLoot sword = loot("minecraft:diamond");
sword.setEnchantments(new KList<>(List.of(new IrisEnchantment().setEnchantment("sharpness"), new IrisEnchantment().setEnchantment("minecraft:wind_burst"))));
table.setLoot(new KList<>(List.of(sword)));
CompatStatus status = gate.evaluate(table);
assertFalse(status.excluded());
assertEquals(1, table.getLoot().size());
assertEquals(1, sword.getEnchantments().size());
assertEquals("sharpness", sword.getEnchantments().getFirst().getEnchantment());
assertFinding(only(status), CompatRegistry.ENCHANTMENT, "minecraft:wind_burst", CompatAction.DROPPED, "loot", "chests/rare", "loot[0].enchantments[1].enchantment");
}
@Test
public void missingPotionEffectDropsEffectAndBlankEffectStays() {
ContentGate gate = gate();
IrisBiome biome = biome("swamp");
biome.setEffects(new KList<>(List.of(new IrisEffect().setPotionEffect("SPEED"), new IrisEffect().setPotionEffect("minecraft:infested"), new IrisEffect().setPotionEffect(""))));
CompatStatus status = gate.evaluate(biome);
assertFalse(status.excluded());
assertEquals(2, biome.getEffects().size());
assertEquals("SPEED", biome.getEffects().get(0).getPotionEffect());
assertEquals("", biome.getEffects().get(1).getPotionEffect());
assertFinding(only(status), CompatRegistry.POTION_EFFECT, "minecraft:infested", CompatAction.DROPPED, "biome", "swamp", "effects[1].potionEffect");
}
@Test
public void scatterEntriesAreDroppedAndBiomeKeepsGenerating() {
ContentGate gate = gate();
IrisBiome biome = biome("plains");
biome.setBiomeScatter(new KList<>(List.of("minecraft:plains", "minecraft:pale_garden")));
biome.setBiomeSkyScatter(new KList<>(List.of("minecraft:pale_garden", "minecraft:desert")));
CompatStatus status = gate.evaluate(biome);
assertFalse(status.excluded());
assertEquals(List.of("minecraft:plains"), biome.getBiomeScatter());
assertEquals(List.of("minecraft:desert"), biome.getBiomeSkyScatter());
assertEquals(2, status.reasons().size());
assertFinding(status.reasons().get(0), CompatRegistry.BIOME, "minecraft:pale_garden", CompatAction.DROPPED, "biome", "plains", "biomeScatter[1]");
assertFinding(status.reasons().get(1), CompatRegistry.BIOME, "minecraft:pale_garden", CompatAction.DROPPED, "biome", "plains", "biomeSkyScatter[0]");
assertEquals(1, gate.report().size());
}
@Test
public void missingDerivativeExcludesBiome() {
ContentGate gate = gate();
IrisBiome biome = biome("pale");
biome.setDerivative("minecraft:pale_garden");
CompatStatus status = gate.evaluate(biome);
assertTrue(status.excluded());
assertFinding(only(status), CompatRegistry.BIOME, "minecraft:pale_garden", CompatAction.EXCLUDED, "biome", "pale", "derivative");
assertEquals("minecraft:pale_garden", biome.getDerivativeKey());
}
@Test
public void missingVanillaDerivativeIsNulled() throws Exception {
ContentGate gate = gate();
IrisBiome biome = biome("pale");
biome.setVanillaDerivative("minecraft:pale_garden");
CompatStatus status = gate.evaluate(biome);
assertFalse(status.excluded());
assertNull(rawField(biome, "vanillaDerivative"));
assertFinding(only(status), CompatRegistry.BIOME, "minecraft:pale_garden", CompatAction.DROPPED, "biome", "pale", "vanillaDerivative");
}
@Test
public void objectPlacementsAreNotDescended() {
ContentGate gate = gate("minecraft:sulfur");
IrisBiome biome = biome("plains");
IrisObjectReplace replace = new IrisObjectReplace()
.setFind(new KList<>(List.of(new IrisBlockData("minecraft:stone"))))
.setReplace(new IrisMaterialPalette().setPalette(new KList<>(List.of(new IrisBlockData("minecraft:sulfur")))));
IrisObjectPlacement placement = new IrisObjectPlacement().setPlace(new KList<>(List.of("trees/oak"))).setEdit(new KList<>(List.of(replace)));
biome.setObjects(new KList<>(List.of(placement)));
CompatStatus status = gate.evaluate(biome);
assertFalse(status.excluded());
assertTrue(status.reasons().isEmpty());
assertTrue(gate.report().isEmpty());
assertEquals(1, biome.getObjects().size());
assertEquals(1, replace.getReplace().getPalette().size());
}
@Test
public void replaceFindIsMatchOnlyButReplacePaletteCounts() {
ContentGate gate = gate("minecraft:sulfur");
Probe matchOnly = new Probe();
matchOnly.setLoadKey("match");
matchOnly.setEdit(new KList<>(List.of(new IrisObjectReplace()
.setFind(new KList<>(List.of(new IrisBlockData("minecraft:sulfur"))))
.setReplace(new IrisMaterialPalette().setPalette(new KList<>(List.of(new IrisBlockData("minecraft:stone"))))))));
assertFalse(gate.evaluate(matchOnly).excluded());
assertTrue(gate.report().isEmpty());
Probe generated = new Probe();
generated.setLoadKey("gen");
generated.setEdit(new KList<>(List.of(new IrisObjectReplace()
.setFind(new KList<>(List.of(new IrisBlockData("minecraft:stone"))))
.setReplace(new IrisMaterialPalette().setPalette(new KList<>(List.of(new IrisBlockData("minecraft:sulfur"))))))));
CompatStatus status = gate.evaluate(generated);
assertTrue(status.excluded());
assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "probe", "gen", "edit[0].replace.palette[0]");
}
@Test
public void decoratorMatchListsAreIgnoredButPaletteCounts() {
ContentGate gate = gate("minecraft:sulfur");
Probe whitelisted = new Probe();
whitelisted.setLoadKey("white");
whitelisted.setDecorators(new KList<>(List.of(new IrisDecorator()
.setWhitelist(new KList<>(List.of(new IrisBlockData("minecraft:sulfur"))))
.setBlacklist(new KList<>(List.of(new IrisBlockData("minecraft:sulfur")))))));
assertFalse(gate.evaluate(whitelisted).excluded());
assertTrue(gate.report().isEmpty());
Probe placing = new Probe();
placing.setLoadKey("place");
placing.setDecorators(new KList<>(List.of(new IrisDecorator().setPalette(new KList<>(List.of(new IrisBlockData("minecraft:sulfur")))))));
CompatStatus status = gate.evaluate(placing);
assertTrue(status.excluded());
assertEquals("decorators[0].palette[0]", only(status).detail());
}
@Test
public void cyclicGraphsTerminate() {
ContentGate gate = gate();
Probe probe = new Probe();
probe.setLoadKey("loop");
probe.setSelf(probe);
probe.setChildren(new KList<>(List.of(probe)));
CompatStatus status = gate.evaluate(probe);
assertFalse(status.excluded());
assertTrue(status.reasons().isEmpty());
}
@Test
public void notReadyProducesNoFindingsNoMutationAndMarksIncomplete() {
ContentGate gate = new ContentGate(null, Map.of(), new PackCompatReport());
IrisBiome biome = biome("custom/lush");
IrisBiomeCustom custom = new IrisBiomeCustom().setId("lush");
custom.setSpawns(new KList<>(List.of(new IrisBiomeCustomSpawn().setType("minecraft:camel"))));
biome.setCustomDerivitives(new KList<>(List.of(custom)));
biome.setLayers(new KList<>(List.of(layer("minecraft:sulfur"))));
CompatStatus status = gate.evaluate(biome);
assertFalse(status.excluded());
assertTrue(status.reasons().isEmpty());
assertEquals(1, custom.getSpawns().size());
assertTrue(gate.report().isEmpty());
assertTrue(gate.report().isIncomplete());
}
@Test
public void unknownSingleRegistryLeavesItsKeysAlone() {
CompatFixtures.FakeRegistries registries = CompatFixtures.registries("minecraft:sulfur");
registries.biomes.clear();
ContentGate gate = gate(registries);
IrisBiome biome = biome("plains");
biome.setDerivative("minecraft:pale_garden");
biome.setBiomeScatter(new KList<>(List.of("minecraft:pale_garden")));
biome.setLayers(new KList<>(List.of(layer("minecraft:sulfur"))));
CompatStatus status = gate.evaluate(biome);
assertTrue(status.excluded());
assertEquals(1, status.reasons().size());
assertEquals(CompatRegistry.BLOCK, only(status).registry());
assertEquals(List.of("minecraft:pale_garden"), biome.getBiomeScatter());
assertTrue(gate.report().isIncomplete());
assertTrue(gate.report().incompleteReason(), gate.report().incompleteReason().contains("biome"));
}
@Test
public void annotatedStringFieldsOnTheRegistrantFollowTheTable() {
ContentGate gate = gate("minecraft:sulfur");
Probe blocked = new Probe();
blocked.setLoadKey("blocked");
blocked.setBlock("minecraft:sulfur");
CompatStatus status = gate.evaluate(blocked);
assertTrue(status.excluded());
assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "probe", "blocked", "block");
Probe items = new Probe();
items.setLoadKey("items");
items.setItems(new KList<>(List.of("minecraft:diamond", "minecraft:mace")));
CompatStatus dropped = gate.evaluate(items);
assertFalse(dropped.excluded());
assertEquals(List.of("minecraft:diamond"), items.getItems());
assertFinding(only(dropped), CompatRegistry.ITEM, "minecraft:mace", CompatAction.DROPPED, "probe", "items", "items[1]");
}
@Test
public void droppedObjectHeldByDirectFieldIsNulled() {
ContentGate gate = gate();
Probe probe = new Probe();
probe.setLoadKey("gear");
probe.setGear(loot("minecraft:mace"));
CompatStatus status = gate.evaluate(probe);
assertFalse(status.excluded());
assertNull(probe.getGear());
assertFinding(only(status), CompatRegistry.ITEM, "minecraft:mace", CompatAction.DROPPED, "probe", "gear", "gear.type");
}
@Test
public void everyFindingReachesReasonsAndReportDedups() {
ContentGate gate = gate("minecraft:sulfur", "minecraft:brimstone");
IrisBiome biome = biome("multi");
biome.setLayers(new KList<>(List.of(layer("minecraft:sulfur", "minecraft:brimstone"), layer("minecraft:sulfur"))));
CompatStatus status = gate.evaluate(biome);
assertTrue(status.excluded());
assertEquals(3, status.reasons().size());
assertEquals(2, gate.report().size());
assertEquals("layers[0].palette[0]", gate.report().byKey().get("BLOCK:minecraft:sulfur").getFirst().detail());
}
@Test
public void customPackBlocksResolveThroughTheLoaderStatus() {
IrisBlockData fine = new IrisBlockData("minecraft:stone");
fine.setCompat(CompatStatus.OK);
IrisBlockData broken = new IrisBlockData("minecraft:sulfur");
broken.setCompat(CompatStatus.excludedBy(List.of(new CompatFinding(CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "block", "broken", "block"))));
Function<String, IrisBlockData> customBlocks = key -> switch (key) {
case "fancy" -> fine;
case "broken" -> broken;
default -> null;
};
ContentGate gate = new ContentGate(CompatFixtures.registries("minecraft:sulfur"), Map::of, new PackCompatReport(), customBlocks);
IrisBiome ok = biome("ok");
ok.setLayers(new KList<>(List.of(layer("fancy"))));
assertFalse(gate.evaluate(ok).excluded());
assertTrue(gate.report().isEmpty());
IrisBiome bad = biome("bad");
bad.setLayers(new KList<>(List.of(layer("broken"))));
CompatStatus status = gate.evaluate(bad);
assertTrue(status.excluded());
assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "biome", "bad", "layers[0].palette[0]");
}
@Test
public void blockRegistrantEvaluatesItsOwnStateWithoutTheLoader() {
Function<String, IrisBlockData> customBlocks = key -> {
throw new IllegalStateException("loader must not be consulted for a block registrant");
};
ContentGate gate = new ContentGate(CompatFixtures.registries("minecraft:sulfur"), Map::of, new PackCompatReport(), customBlocks);
IrisBlockData registrant = new IrisBlockData("minecraft:sulfur");
registrant.setLoadKey("sulfur-variant");
CompatStatus status = gate.evaluate(registrant);
assertTrue(status.excluded());
assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "block", "sulfur-variant", "block");
IrisBlockData revived = new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:sand"));
revived.setLoadKey("sulfur-backup");
CompatStatus substituted = gate.evaluate(revived);
assertFalse(substituted.excluded());
assertEquals("block (backup minecraft:sand)", only(substituted).detail());
}
@Test
public void nullRegistrantIsOk() {
assertNotNull(gate().evaluate(null));
assertFalse(gate().evaluate(null).excluded());
}
}
@@ -0,0 +1,96 @@
package art.arcane.iris.core.compat;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.object.IrisBlockData;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import java.io.File;
import java.nio.file.Files;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisBlockDataResolutionTest {
private static CompatFixtures.FakeRegistries registries;
@BeforeClass
public static void bindPlatform() {
registries = CompatFixtures.registries("minecraft:sulfur", "minecraft:brimstone", "minecraft:grass");
CompatFixtures.bind(registries);
}
@AfterClass
public static void unbindPlatform() {
CompatFixtures.unbind();
}
private static IrisData pack(String dimensionJson, String customBlockJson) throws Exception {
File folder = Files.createTempDirectory("iris-compat-blockdata").toFile();
if (dimensionJson != null) {
File dimensions = new File(folder, "dimensions");
assertTrue(dimensions.mkdirs());
Files.writeString(new File(dimensions, "overworld.json").toPath(), dimensionJson);
}
if (customBlockJson != null) {
File blocks = new File(folder, "blocks");
assertTrue(blocks.mkdirs());
Files.writeString(new File(blocks, "fancy.json").toPath(), customBlockJson);
}
return IrisData.get(folder);
}
@Test
public void presentBlockResolvesWithProperties() throws Exception {
IrisData data = pack(null, null);
IrisBlockData entry = new IrisBlockData("minecraft:oak_log");
entry.getData().put("axis", "y");
assertEquals("minecraft:oak_log[axis=y]", entry.getBlockData(data).key());
assertEquals("minecraft:stone", new IrisBlockData("STONE").getBlockData(data).key());
}
@Test
public void backupRevivesMissingBlock() throws Exception {
IrisData data = pack(null, null);
IrisBlockData entry = new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:sand"));
assertEquals("minecraft:sand", entry.getBlockData(data).key());
IrisBlockData chained = new IrisBlockData("minecraft:sulfur")
.setBackup(new IrisBlockData("minecraft:brimstone").setBackup(new IrisBlockData("minecraft:sand")));
assertEquals("minecraft:sand", chained.getBlockData(data).key());
}
@Test
public void missingBlockWithoutBackupFallsBackToAir() throws Exception {
IrisData data = pack(null, null);
IrisBlockData entry = new IrisBlockData("minecraft:sulfur");
assertTrue(entry.getBlockData(data).isAir());
assertTrue(new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:brimstone")).getBlockData(data).isAir());
}
@Test
public void dimensionFallbackAppliesBeforeBackup() throws Exception {
IrisData data = pack("{\"name\":\"overworld\",\"blockFallbacks\":{\"minecraft:sulfur\":\"minecraft:stone\"}}", null);
IrisBlockData entry = new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:sand"));
assertEquals("minecraft:stone", entry.getBlockData(data).key());
}
@Test
public void legacyRenameAppliesOnEveryPlatform() throws Exception {
IrisData data = pack(null, null);
assertEquals("minecraft:short_grass", new IrisBlockData("minecraft:grass").getBlockData(data).key());
assertTrue(data.getCompatReport().isEmpty());
}
@Test
public void customPackBlockStillResolvesFirstAndUsesItsOwnBackup() throws Exception {
IrisData data = pack(null, "{\"block\":\"minecraft:sulfur\",\"backup\":{\"block\":\"minecraft:sand\"}}");
IrisBlockData entry = new IrisBlockData("fancy");
assertEquals("minecraft:sand", entry.getBlockData(data).key());
assertFalse(data.getCompatReport().isEmpty());
assertEquals(CompatAction.SUBSTITUTED, data.getCompatReport().findings().getFirst().action());
assertEquals("block", data.getCompatReport().findings().getFirst().subjectType());
}
}
@@ -0,0 +1,71 @@
package art.arcane.iris.core.compat;
import org.junit.Test;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class PackCompatReportTest {
private static CompatFinding finding(CompatAction action, String subjectKey, String detail) {
return new CompatFinding(CompatRegistry.BLOCK, "minecraft:sulfur", action, "Biome", subjectKey, detail);
}
@Test
public void recordDedupsOnRegistryKeyActionAndSubject() {
PackCompatReport report = new PackCompatReport();
report.record(finding(CompatAction.EXCLUDED, "cave/sulfur", "layers[0].palette[1]"));
report.record(finding(CompatAction.EXCLUDED, "cave/sulfur", "layers[2].palette[0]"));
report.record(finding(CompatAction.SUBSTITUTED, "cave/sulfur", "layers[1].palette[0] (backup minecraft:sand)"));
report.record(finding(CompatAction.EXCLUDED, "desert/flats", "layers[0].palette[0]"));
report.record(null);
assertEquals(3, report.size());
List<CompatFinding> findings = report.findings();
assertEquals("layers[0].palette[1]", findings.get(0).detail());
assertEquals(CompatAction.SUBSTITUTED, findings.get(1).action());
assertEquals("desert/flats", findings.get(2).subjectKey());
Map<CompatAction, Integer> counts = report.countsByAction();
assertEquals(Integer.valueOf(2), counts.get(CompatAction.EXCLUDED));
assertEquals(Integer.valueOf(1), counts.get(CompatAction.SUBSTITUTED));
assertEquals(1, report.distinctKeys());
assertEquals(3, report.byKey().get("BLOCK:minecraft:sulfur").size());
}
@Test
public void markIncompleteKeepsFirstReason() {
PackCompatReport report = new PackCompatReport();
assertFalse(report.isIncomplete());
report.markIncomplete("biome registry not ready");
report.markIncomplete("structure registry not ready");
assertTrue(report.isIncomplete());
assertEquals("biome registry not ready", report.incompleteReason());
report.clear();
assertFalse(report.isIncomplete());
assertNull(report.incompleteReason());
}
@Test
public void clearForgetsFindingsForHotload() {
PackCompatReport report = new PackCompatReport();
report.record(finding(CompatAction.DROPPED, "cave/sulfur", "x"));
assertFalse(report.isEmpty());
report.clear();
assertTrue(report.isEmpty());
assertEquals(0, report.size());
assertTrue(report.bootLines("overworld", "26.1.2", 3).isEmpty());
assertEquals("", report.summaryLine("26.1.2"));
}
@Test
public void findingLineAndDedupKey() {
CompatFinding finding = finding(CompatAction.EXCLUDED, "cave/sulfur", "layers[0].palette[1]");
assertEquals("minecraft:sulfur (block): excluded Biome cave/sulfur at layers[0].palette[1]", finding.line());
assertEquals("BLOCK:minecraft:sulfur|EXCLUDED|Biome|cave/sulfur", finding.dedupKey());
CompatFinding blank = new CompatFinding(CompatRegistry.ENTITY, "minecraft:camel", CompatAction.DROPPED, null, null, null);
assertEquals("minecraft:camel (entity): dropped", blank.line());
}
}
@@ -1,5 +1,8 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatRegistry;
import org.junit.Rule;
import org.junit.Test;
import org.junit.Assume;
@@ -16,6 +19,7 @@ import java.util.Optional;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
@@ -61,6 +65,55 @@ public class PackValidationCacheTest {
cache, "content", "context", List.of("other")).isEmpty());
}
@Test
public void cacheRoundTripsCompatFindingsAndMinecraftVersion() throws Exception {
Path cache = new File(temporaryFolder.newFolder("compat-round-trip"), "validation.json").toPath();
CompatFinding excluded = new CompatFinding(
CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED,
"Biome", "cave/sulfur-grotto", "layers[0].palette[1]");
CompatFinding dropped = new CompatFinding(
CompatRegistry.ENTITY, "minecraft:camel", CompatAction.DROPPED, "Spawner", "desert", "spawns[0]");
PackValidationCache.save(cache, "content", "context", List.of(new PackValidationResult(
"overworld", List.of(), List.of("warning"), 7L, List.of(excluded, dropped), "26.1.2")));
PackValidationResult loaded = PackValidationCache.load(
cache, "content", "context", List.of("overworld")).orElseThrow().getFirst();
assertEquals("26.1.2", loaded.getMinecraftVersion());
assertEquals(List.of(excluded, dropped), loaded.getCompatFindings());
}
@Test
public void resultsWithoutGatingRoundTripAsEmptyFindings() throws Exception {
Path cache = new File(temporaryFolder.newFolder("compat-empty"), "validation.json").toPath();
PackValidationCache.save(cache, "content", "context", List.of(
new PackValidationResult("overworld", List.of(), List.of(), 1L)));
PackValidationResult loaded = PackValidationCache.load(
cache, "content", "context", List.of("overworld")).orElseThrow().getFirst();
assertTrue(loaded.getCompatFindings().isEmpty());
assertNull(loaded.getMinecraftVersion());
}
@Test
public void cacheWithAnUnreadableFindingIsIgnored() throws Exception {
Path cache = new File(temporaryFolder.newFolder("compat-corrupt"), "validation.json").toPath();
Files.writeString(cache, """
{
"schemaVersion": 3,
"contentFingerprint": "content",
"contextFingerprint": "context",
"results": [
{"packName":"overworld","blockingErrors":[],"warnings":[],"validatedAtMillis":1,
"compatFindings":[{"registry":"NOT_A_REGISTRY","key":"minecraft:sulfur","action":"EXCLUDED"}]}
]
}
""", StandardCharsets.UTF_8);
assertTrue(PackValidationCache.load(cache, "content", "context", List.of("overworld")).isEmpty());
}
@Test
public void corruptCacheIsIgnored() throws Exception {
Path cache = new File(temporaryFolder.newFolder("corrupt"), "validation.json").toPath();
@@ -77,7 +130,7 @@ public class PackValidationCacheTest {
Path cache = new File(temporaryFolder.newFolder("duplicate"), "validation.json").toPath();
Files.writeString(cache, """
{
"schemaVersion": 2,
"schemaVersion": 3,
"contentFingerprint": "content",
"contextFingerprint": "context",
"results": [
@@ -0,0 +1,130 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatRegistry;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class PackValidatorCompatTest {
private static CompatFinding block(CompatAction action, String subjectKey) {
return new CompatFinding(
CompatRegistry.BLOCK, "minecraft:sulfur", action, "Biome", subjectKey, "layers[0].palette[1]");
}
private static CompatFinding entity(CompatAction action, String subjectKey) {
return new CompatFinding(
CompatRegistry.ENTITY, "minecraft:camel", action, "Spawner", subjectKey, "spawns[0]");
}
@Test
public void excludedDimensionBecomesABlockingError() {
List<String> blockingErrors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
PackValidator.applyCompatFindings(
"26.1.2",
true,
List.of(block(CompatAction.EXCLUDED, "cave/sulfur"), entity(CompatAction.DROPPED, "desert")),
blockingErrors,
warnings);
assertEquals(1, blockingErrors.size());
assertEquals(
"Pack cannot generate on Minecraft 26.1.2: minecraft:sulfur (block), minecraft:camel (entity)",
blockingErrors.getFirst());
}
@Test
public void unexcludedDimensionAddsNoBlockingError() {
List<String> blockingErrors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
PackValidator.applyCompatFindings(
"26.1.2", false, List.of(block(CompatAction.DROPPED, "cave/sulfur")), blockingErrors, warnings);
assertTrue(blockingErrors.toString(), blockingErrors.isEmpty());
}
@Test
public void excludedDimensionWithoutFindingsStillNamesTheVersion() {
List<String> blockingErrors = new ArrayList<>();
PackValidator.applyCompatFindings("26.1.2", true, List.of(), blockingErrors, new ArrayList<>());
assertEquals(
"Pack cannot generate on Minecraft 26.1.2: the dimension composes content this server does not have",
blockingErrors.getFirst());
assertTrue(PackValidator.excludedDimensionError(null, List.of()).contains("Minecraft unknown"));
}
@Test
public void contentKeyWarningsForGatedKeysAreSuppressed() {
List<String> warnings = new ArrayList<>(List.of(
"Unknown block key 'minecraft:sulfur' (missing from the block registry)",
"Unknown entity key 'minecraft:camel' (missing from the entity registry)",
"Unknown block key 'minecraft:cheese' (missing from the block registry)",
"Duplicate generator 'overworld'"));
PackValidator.applyCompatFindings(
"26.1.2",
false,
List.of(block(CompatAction.EXCLUDED, "cave/sulfur"), entity(CompatAction.DROPPED, "desert")),
new ArrayList<>(),
warnings);
assertEquals(List.of(
"Unknown block key 'minecraft:cheese' (missing from the block registry)",
"Duplicate generator 'overworld'"), warnings);
}
@Test
public void bootLinesCapSubjectsPerKeyAndSummaryCountsActions() {
PackValidationResult result = new PackValidationResult(
"overworld",
List.of(),
List.of(),
1L,
List.of(
block(CompatAction.EXCLUDED, "cave/sulfur-grotto"),
block(CompatAction.EXCLUDED, "desert/sulfur-flats"),
block(CompatAction.DROPPED, "cave/sulfur-grotto"),
block(CompatAction.SUBSTITUTED, "desert/dunes"),
entity(CompatAction.EXCLUDED, "camel")),
"26.1.2");
List<String> lines = PackValidator.compatBootLines(result, null, 3);
assertEquals(4, lines.size());
assertEquals("Pack 'overworld': content unavailable on Minecraft 26.1.2", lines.get(0));
assertTrue(lines.get(1), lines.get(1).startsWith(" minecraft:sulfur (block): "));
assertTrue(lines.get(1), lines.get(1).endsWith("; +1 more"));
assertEquals(3, lines.get(1).split("; ").length - 1);
assertTrue(lines.get(2), lines.get(2).startsWith(" minecraft:camel (entity): "));
assertFalse(lines.get(2), lines.get(2).contains("more"));
assertTrue(lines.get(3), lines.get(3).contains("/iris pack compat overworld"));
assertEquals(
"2 content keys unavailable on Minecraft 26.1.2: 3 excluded, 1 dropped, 1 substituted.",
PackValidator.compatSummary(result, null));
assertEquals(
"2 content keys unavailable on Minecraft 26.2: 3 excluded, 1 dropped, 1 substituted.",
PackValidator.compatSummary(result, "26.2"));
}
@Test
public void bootLinesAndSummaryAreEmptyWithoutFindings() {
PackValidationResult result = new PackValidationResult("overworld", List.of(), List.of(), 1L);
assertTrue(PackValidator.compatBootLines(result, "26.1.2", 3).isEmpty());
assertEquals("", PackValidator.compatSummary(result, "26.1.2"));
assertTrue(PackValidator.compatBootLines(null, "26.1.2", 3).isEmpty());
assertEquals("", PackValidator.compatSummary(null, "26.1.2"));
}
}
@@ -252,6 +252,17 @@ public class SchemaBuilderParityTest {
assertEquals("boolean", properties.getJSONObject("requireObjectSurfaceSupport").getString("type"));
}
@Test
public void dimensionSchemaListsBlockFallbacksAsPlainObjectProperty() {
JSONObject properties = rootProperties(
new SchemaBuilder(IrisDimension.class, structureSchemaData()).construct());
JSONObject fallbacks = properties.getJSONObject("blockFallbacks");
assertEquals("object", fallbacks.getString("type"));
assertFalse(fallbacks.has("!top"));
assertFalse(fallbacks.has("anyOf"));
}
@Test
public void jigsawStructureSchemaExposesBranchFailurePolicy() {
JSONObject schema = new SchemaBuilder(IrisStructure.class, structureSchemaData()).construct();
@@ -0,0 +1,145 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.engine.framework;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatRegistry;
import art.arcane.iris.core.compat.CompatStatus;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.structure.StructureAssemblyResult;
import art.arcane.iris.engine.framework.structure.StructureAssemblyStatus;
import art.arcane.iris.engine.object.IrisDirection;
import art.arcane.iris.engine.object.IrisJigsawConnector;
import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisJigsawPieceEntry;
import art.arcane.iris.engine.object.IrisJigsawPool;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.object.JigsawJoint;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.volmlib.util.math.RNG;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class StructureAssemblerCompatTest {
@BeforeClass
public static void bindPlatform() {
IrisPlatforms.unbind();
PlatformBlockState block = mock(PlatformBlockState.class);
PlatformRegistries registries = mock(PlatformRegistries.class);
when(registries.block(anyString())).thenReturn(block);
IrisPlatform platform = mock(IrisPlatform.class);
when(platform.registries()).thenReturn(registries);
IrisPlatforms.bind(platform);
}
@AfterClass
public static void unbindPlatform() {
IrisPlatforms.unbind();
}
@Test
public void anExcludedPieceIsNeverDrawnFromItsPool() {
IrisData data = mock(IrisData.class);
IrisJigsawPiece good = piece("good-object");
IrisJigsawPiece bad = excluded(piece("bad-object"));
when(data.load(IrisJigsawPool.class, "start", false)).thenReturn(pool("good-piece", "bad-piece"));
when(data.load(IrisJigsawPool.class, "terminal", false)).thenReturn(new IrisJigsawPool());
when(data.load(IrisJigsawPiece.class, "good-piece", false)).thenReturn(good);
when(data.load(IrisJigsawPiece.class, "bad-piece", false)).thenReturn(bad);
when(data.load(IrisObject.class, "good-object", false)).thenReturn(new IrisObject(1, 1, 1));
when(data.load(IrisObject.class, "bad-object", false)).thenReturn(new IrisObject(1, 1, 1));
for (int seed = 0; seed < 32; seed++) {
StructureAssemblyResult result = StructureAssembler
.forData(data, structure("start"), new IrisPosition(0, 0, 0))
.assemble(new RNG(seed));
assertTrue(result.detail(), result.hasOutput());
assertTrue(result.pieces().toString(), result.pieces().stream()
.allMatch(placed -> "good-object".equals(placed.getPiece().getObject())));
}
}
@Test
public void aPoolWithOnlyExcludedPiecesStopsTheAssemblyWithoutThrowing() {
IrisData data = mock(IrisData.class);
IrisJigsawPool startPool = pool("bad-piece");
startPool.setCompat(CompatStatus.excludedBy(List.of(finding(CompatAction.EXCLUDED))));
when(data.load(IrisJigsawPool.class, "start", false)).thenReturn(startPool);
when(data.load(IrisJigsawPool.class, "terminal", false)).thenReturn(new IrisJigsawPool());
when(data.load(IrisJigsawPiece.class, "bad-piece", false)).thenReturn(excluded(piece("bad-object")));
when(data.load(IrisObject.class, "bad-object", false)).thenReturn(new IrisObject(1, 1, 1));
StructureAssemblyResult result = StructureAssembler
.forData(data, structure("start"), new IrisPosition(0, 0, 0))
.assemble(new RNG(3));
assertFalse(result.hasOutput());
assertEquals(StructureAssemblyStatus.FAILED_RULES, result.status());
}
private static IrisJigsawPiece excluded(IrisJigsawPiece piece) {
piece.setCompat(CompatStatus.excludedBy(List.of(finding(CompatAction.EXCLUDED))));
return piece;
}
private static CompatFinding finding(CompatAction action) {
return new CompatFinding(CompatRegistry.BLOCK, "minecraft:sulfur", action, "jigsaw piece", "bad-piece", "object bad-object");
}
private static IrisJigsawPiece piece(String object) {
IrisJigsawPiece piece = new IrisJigsawPiece().setObject(object).setRotatable(false);
piece.getConnectors().add(new IrisJigsawConnector()
.setPosition(new IrisPosition())
.setDirection(IrisDirection.EAST_POSITIVE_X)
.setPool("terminal")
.setName("start")
.setTargetName("door")
.setJoint(JigsawJoint.ROLLABLE));
return piece;
}
private static IrisJigsawPool pool(String... pieces) {
IrisJigsawPool pool = new IrisJigsawPool();
for (String piece : pieces) {
pool.getPieces().add(new IrisJigsawPieceEntry().setPiece(piece).setWeight(1));
}
return pool;
}
private static IrisStructure structure(String startPool) {
return new IrisStructure().setStartPool(startPool).setMaxDepth(1).setMaxSizeChunks(8);
}
}

Some files were not shown because too many files have changed in this diff Show More