This commit is contained in:
Brian Neumann-Fopiano
2026-08-01 22:39:09 -04:00
parent 324cf9e095
commit a8217b42e9
220 changed files with 10626 additions and 1661 deletions
@@ -277,6 +277,8 @@ public class IrisSettings {
public boolean adjustVanillaHeight = false;
public boolean autoIngestDatapacks = true;
public boolean autoImportDatapackStructures = true;
/** Unresolved pack content keys and bad block-state properties become blocking pack errors. -Diris.strictContent overrides. */
public boolean strictContentKeys = false;
public int spinh = -20;
public int spins = 7;
public int spinb = 8;
@@ -18,6 +18,7 @@
package art.arcane.iris.core.gui;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.engine.framework.Engine;
import java.awt.GraphicsEnvironment;
@@ -67,4 +68,34 @@ public final class GuiHost {
public static boolean isAvailable() {
return !desktopSuppressed && !GraphicsEnvironment.isHeadless();
}
/**
* Outcome of a server triggered desktop gui launch request.
*/
public enum ServerGuiLaunch {
/**
* The gui was asked for and a display environment exists.
*/
OPEN,
/**
* The gui was not asked for, or server launched guis are turned off in settings.
*/
DISABLED,
/**
* The gui was asked for but the jvm is headless or the desktop is suppressed.
*/
UNAVAILABLE
}
/**
* Decides whether a server triggered job may open a desktop gui. Callers must not attempt an
* awt launch on anything other than {@link ServerGuiLaunch#OPEN}, since awt throws on a headless jvm.
*/
public static ServerGuiLaunch serverGuiLaunch(boolean requested) {
if (!requested || !IrisSettings.get().getGui().isUseServerLaunchedGuis()) {
return ServerGuiLaunch.DISABLED;
}
return isAvailable() ? ServerGuiLaunch.OPEN : ServerGuiLaunch.UNAVAILABLE;
}
}
@@ -23,7 +23,6 @@ import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.protocol.IrisMessage;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.protocol.IrisProtocolServer;
import art.arcane.iris.core.pregenerator.IrisPregenerator;
import art.arcane.iris.core.pregenerator.PregenApiPhase;
@@ -106,8 +105,11 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource {
min = new Position2(Integer.MAX_VALUE, Integer.MAX_VALUE);
service = Executors.newVirtualThreadPerTaskExecutor();
if (IrisSettings.get().getGui().isUseServerLaunchedGuis() && task.isGui()) {
open();
switch (GuiHost.serverGuiLaunch(task.isGui())) {
case OPEN -> open();
case UNAVAILABLE -> IrisLogging.info("Pregen GUI unavailable (headless), continuing");
case DISABLED -> {
}
}
worker = new Thread(() -> {
@@ -20,8 +20,6 @@ package art.arcane.iris.core.loader;
import com.google.gson.GsonBuilder;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.iris.util.common.plugin.VolmitSender;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -54,6 +52,4 @@ public abstract class IrisRegistrant {
return getLoadFile();
}
public abstract void scanForErrors(JSONObject p, VolmitSender sender);
}
@@ -24,6 +24,14 @@ public final class ClientUiMessages {
"iris.client.vision.not_iris_world",
"Not an Iris world"
);
public static final TextKey VISION_SERVER_WITHOUT_IRIS = TextKey.of(
"iris.client.vision.server_without_iris",
"This server does not run Iris"
);
public static final TextKey VISION_VERSION_MISMATCH = TextKey.of(
"iris.client.vision.version_mismatch",
"Iris version mismatch between client and server"
);
public static final TextKey VISION_NO_DIMENSION_DATA = TextKey.of(
"iris.client.vision.no_dimension_data",
"no dimension data"
@@ -40,6 +48,14 @@ public final class ClientUiMessages {
"iris.client.vision.dimension_pack",
"{dimension} pack {pack}"
);
public static final TextKey CREATE_STRUCTURES_REQUIRED_TITLE = TextKey.of(
"iris.client.create.structures_required_title",
"Iris requires Generate Structures"
);
public static final TextKey CREATE_STRUCTURES_REQUIRED_BODY = TextKey.of(
"iris.client.create.structures_required_body",
"Iris places its own structures through the structure generation step, and refuses to load a world that was created with Generate Structures off. Turn Generate Structures back on, or choose a different world type."
);
public static final TextKey TOAST_STUDIO_HOTLOAD = TextKey.of("iris.client.toast.studio_hotload", "Studio Hotload");
public static final PluralKey TOAST_CHANGED_FILES = PluralKey.of(
"iris.client.toast.changed_files",
@@ -60,6 +76,7 @@ public final class ClientUiMessages {
public static final TextKey WHAT_HEIGHT = TextKey.of("iris.client.what.height", "Height: {height} ({x}, {z})");
public static final TextKey PREGEN_STATS = TextKey.of("iris.client.pregen.stats", "{done} / {total} ({percent}%)");
public static final TextKey PREGEN_PAUSED = TextKey.of("iris.client.pregen.paused", "PAUSED");
public static final TextKey PREGEN_STALE = TextKey.of("iris.client.pregen.stale", "no updates for {seconds}s");
public static final TextKey PREGEN_RATE = TextKey.of("iris.client.pregen.rate", "{rate}/s");
public static final TextKey PREGEN_RATE_ETA = TextKey.of("iris.client.pregen.rate_eta", "{rate}/s ETA {eta}");
public static final TextKey DURATION_HOURS_MINUTES = TextKey.of("iris.client.duration.hours_minutes", "{hours}h {minutes}m");
@@ -71,10 +88,14 @@ public final class ClientUiMessages {
VISION_CONNECTING,
VISION_NOT_CONNECTED,
VISION_NOT_IRIS_WORLD,
VISION_SERVER_WITHOUT_IRIS,
VISION_VERSION_MISMATCH,
VISION_NO_DIMENSION_DATA,
VISION_HEADER_DETAIL,
VISION_FOOTER_HINT,
VISION_DIMENSION_PACK,
CREATE_STRUCTURES_REQUIRED_TITLE,
CREATE_STRUCTURES_REQUIRED_BODY,
TOAST_STUDIO_HOTLOAD,
TOAST_CHANGED_FILES,
TOAST_RELOAD_FAILED,
@@ -88,6 +109,7 @@ public final class ClientUiMessages {
WHAT_HEIGHT,
PREGEN_STATS,
PREGEN_PAUSED,
PREGEN_STALE,
PREGEN_RATE,
PREGEN_RATE_ETA,
DURATION_HOURS_MINUTES,
@@ -10,6 +10,7 @@ import art.arcane.volmlib.util.localization.LocalizationCandidate;
import art.arcane.volmlib.util.localization.LocalizationIssue;
import art.arcane.volmlib.util.localization.LocalizationManager;
import art.arcane.volmlib.util.localization.LocalizationReloadResult;
import art.arcane.volmlib.util.localization.LocalizationSnapshot;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.localization.MessageArgumentKind;
import art.arcane.volmlib.util.localization.MessageArgs;
@@ -34,6 +35,8 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Pattern;
public final class IrisLanguage {
@@ -45,6 +48,13 @@ public final class IrisLanguage {
private static final LocalizationManager MANAGER = new LocalizationManager(
LocalizationCandidate.english(CATALOG, PluralSelector.oneOther())
);
/**
* Memoized argument-free {@link #plain(MessageKey)} results. HUD and overlay code calls it several times
* per frame for fixed labels; resolve plus placeholder render plus colour clean is not free at 60fps.
* Keyed by message id and pinned to the snapshot it was resolved against, so a locale reload publishes a
* new snapshot and the whole memo is discarded. Bounded by the catalog size.
*/
private static final AtomicReference<PlainMemo> PLAIN_MEMO = new AtomicReference<>(null);
private static volatile File dataFolder;
private static volatile File watchedFile;
@@ -163,7 +173,19 @@ public final class IrisLanguage {
}
public static String plain(MessageKey key) {
return plain(key, MessageArgs.empty());
LocalizationSnapshot snapshot = MANAGER.snapshot();
PlainMemo memo = PLAIN_MEMO.get();
if (memo == null || memo.snapshot() != snapshot) {
memo = new PlainMemo(snapshot, new ConcurrentHashMap<>());
PLAIN_MEMO.set(memo);
}
String cached = memo.values().get(key.id());
if (cached != null) {
return cached;
}
String resolved = plain(key, MessageArgs.empty());
memo.values().put(key.id(), resolved);
return resolved;
}
public static String plain(MessageKey key, MessageArgs arguments) {
@@ -464,4 +486,7 @@ public final class IrisLanguage {
private record RenderedArgument(String token, MessageArgument argument) {
}
private record PlainMemo(LocalizationSnapshot snapshot, Map<String, String> values) {
}
}
@@ -1153,7 +1153,7 @@ public final class ModdedCommandMessages {
);
public static final TextKey MODDED_WORLD_COMMANDS_FAILED_WRITE_SERVER_PROPERTIES_CHECK_FILE_PERMISSIONS_SET_LEVEL_TYPE = TextKey.of(
"iris.modded.moddedworldcommands.failed_write_server_properties_check_file_permissions_set_level_type",
"Failed to write server.properties; check file permissions and set level-type manually."
"Could not update server.properties (missing at the server working directory, or unwritable); see the server log, or set level-type manually."
);
public static final TextKey MODDED_WORLD_COMMANDS_IRIS_MAIN_WORLD_SET_PRESET_SEED = TextKey.of(
"iris.modded.moddedworldcommands.iris_main_world_set_preset_seed",
@@ -18,8 +18,12 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.engine.object.IrisObjectIO;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockProperty;
import art.arcane.iris.spi.PlatformNumericRange;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
@@ -37,11 +41,15 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public final class ContentKeyValidator {
private static final int MAX_SUGGESTION_SCANS = 4096;
private static final int MAX_OBJECT_PALETTE_SCANS = 20_000;
private static final String DEFAULT_NAMESPACE = "minecraft";
private static final String STRICT_PROPERTY = "iris.strictContent";
private static final AtomicBoolean WARNED_EMPTY_REGISTRIES = new AtomicBoolean();
private ContentKeyValidator() {
}
@@ -100,6 +108,163 @@ public final class ContentKeyValidator {
return List.copyOf(errors.values());
}
/**
* Validates the {@code [prop=value,...]} section of every referenced block state against the platform's declared
* properties for that block.
* <p>
* Only blocks the platform declares at least one property for are checked: a block key that is absent from
* {@link PlatformRegistries#blockStateProperties()}, or present with an empty list, carries no property knowledge
* (pack-registered blocks and mod provider blocks land there) and a typo cannot be told from a valid custom
* property.
*/
public static List<String> validateBlockStateProperties(PlatformRegistries registries,
Collection<String> referencedBlockStates) {
if (registries == null || referencedBlockStates == null || referencedBlockStates.isEmpty()) {
return List.of();
}
Map<String, List<PlatformBlockProperty>> declared = registries.blockStateProperties();
if (declared == null || declared.isEmpty()) {
return List.of();
}
List<String> messages = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (String raw : referencedBlockStates) {
String properties = propertySectionOf(raw);
if (properties == null || properties.isBlank()) {
continue;
}
String base = normalizeKey(raw);
if (base == null) {
continue;
}
List<PlatformBlockProperty> known = declared.get(base);
if (known == null || known.isEmpty()) {
continue;
}
for (String pair : properties.split(",")) {
int equals = pair.indexOf('=');
if (equals <= 0) {
continue;
}
String name = pair.substring(0, equals).trim();
String value = pair.substring(equals + 1).trim();
if (name.isEmpty()) {
continue;
}
String message = describeProperty(base, name, value, known);
if (message != null && seen.add(message)) {
messages.add(message);
}
}
}
return List.copyOf(messages);
}
private static String describeProperty(String base, String name, String value, List<PlatformBlockProperty> known) {
PlatformBlockProperty match = null;
List<String> names = new ArrayList<>(known.size());
for (PlatformBlockProperty property : known) {
names.add(property.name());
if (property.name().equalsIgnoreCase(name)) {
match = property;
}
}
if (match == null) {
String suggestion = nearestKey(name, names);
StringBuilder sb = new StringBuilder(96);
sb.append("Block '").append(base).append("' has no property '").append(name).append('\'');
if (suggestion != null) {
sb.append(" - did you mean '").append(suggestion).append("'?");
} else {
sb.append(" (has: ").append(String.join(", ", names)).append(')');
}
return sb.toString();
}
List<Object> allowed = match.allowedValues();
if (allowed == null || allowed.isEmpty()) {
// The adapters disagree on how a numeric property describes itself: modded enumerates every legal
// integer into allowedValues, Bukkit leaves that empty and publishes bounds instead. Validating the
// range here is what makes 'minecraft:water[level=99]' report the same thing on both platforms.
return describeNumericRange(base, match, value);
}
List<String> allowedText = new ArrayList<>(allowed.size());
for (Object candidate : allowed) {
String text = String.valueOf(candidate);
allowedText.add(text);
if (text.equalsIgnoreCase(value)) {
return null;
}
}
return "Block '" + base + "' property '" + match.name() + "' does not accept '" + value
+ "' (allowed: " + String.join(", ", allowedText) + ")";
}
/**
* Range check for a property that declares bounds but cannot enumerate its values. A value that is not a number
* at all is reported too - a numeric property never accepts one.
*/
private static String describeNumericRange(String base, PlatformBlockProperty match, String value) {
if (!match.hasNumericRange()) {
return null;
}
PlatformNumericRange range = match.numericRange();
String bounds = "expected "
+ (range.exclusiveMinimum() ? "greater than " : "at least ") + describeBound(range.minimum())
+ " and " + (range.exclusiveMaximum() ? "less than " : "at most ") + describeBound(range.maximum());
double parsed;
try {
parsed = Double.parseDouble(value);
} catch (NumberFormatException e) {
return "Block '" + base + "' property '" + match.name() + "' is numeric and does not accept '" + value
+ "' (" + bounds + ")";
}
boolean belowMinimum = range.exclusiveMinimum() ? parsed <= range.minimum() : parsed < range.minimum();
boolean aboveMaximum = range.exclusiveMaximum() ? parsed >= range.maximum() : parsed > range.maximum();
if (!belowMinimum && !aboveMaximum) {
return null;
}
return "Block '" + base + "' property '" + match.name() + "' does not accept '" + value + "' (" + bounds + ")";
}
private static String describeBound(double bound) {
return bound == Math.rint(bound) && !Double.isInfinite(bound)
? String.valueOf((long) bound)
: String.valueOf(bound);
}
static String propertySectionOf(String raw) {
if (raw == null) {
return null;
}
int open = raw.indexOf('[');
if (open < 0) {
return null;
}
int close = raw.indexOf(']', open);
return close < 0 ? raw.substring(open + 1) : raw.substring(open + 1, close);
}
/**
* Whether unresolved pack content keys are blocking errors instead of warnings. Enabled by
* {@code -Diris.strictContent} or {@code general.strictContentKeys} in settings.json; the system property wins.
*/
public static boolean strictContent() {
String property = System.getProperty(STRICT_PROPERTY);
if (property != null) {
return property.isEmpty() || Boolean.parseBoolean(property);
}
try {
return IrisPlatforms.isBound() && IrisSettings.get().getGeneral().isStrictContentKeys();
} catch (Throwable e) {
return false;
}
}
static String namespaceOf(String key) {
int colon = key.indexOf(':');
return colon < 0 ? DEFAULT_NAMESPACE : key.substring(0, colon);
@@ -232,37 +397,78 @@ public final class ContentKeyValidator {
return prev[lb];
}
static void runContentKeyValidation(File packFolder, List<String> warnings) {
/**
* Unresolved-key and block-property issues for a pack, split by how hard they are allowed to bite.
*
* @param strict issues the caller may promote to blocking errors under {@link #strictContent()}
* @param advisory issues that stay warnings whatever the mode - see {@link #collectContentKeyIssues(File)}
*/
record ContentKeyIssues(List<String> strict, List<String> advisory) {
static ContentKeyIssues none() {
return new ContentKeyIssues(List.of(), List.of());
}
}
/**
* Collects unresolved-key and block-property issues for a pack. Messages come back in report order; the caller
* decides whether the {@link ContentKeyIssues#strict()} half is warnings or blocking errors (see
* {@link #strictContent()}).
* <p>
* Findings whose only source is an object palette are {@link ContentKeyIssues#advisory()} and never blocking. A
* {@code .iob} palette is a build artifact, not authored text: it carries whatever key the block had when the
* object was saved, so a decade-old community object legitimately names {@code minecraft:grass} or
* {@code minecraft:grass_path}. Those resolve at generation time through the Bukkit compatibility rewrite table
* and are already reported as warnings; refusing to load the pack over them would reject most of the object
* library over keys the author cannot edit and the engine handles.
*/
static ContentKeyIssues collectContentKeyIssues(File packFolder) {
List<String> strict = new ArrayList<>();
List<String> advisory = new ArrayList<>();
try {
if (!IrisPlatforms.isBound()) {
return;
return ContentKeyIssues.none();
}
PlatformRegistries registries = IrisPlatforms.get().registries();
if (registries == null) {
return;
return ContentKeyIssues.none();
}
List<String> blockKeys = registries.blockKeys();
List<String> itemKeys = registries.itemKeys();
List<String> entityKeys = registries.entityKeys();
if (blockKeys == null || blockKeys.isEmpty() || itemKeys == null || itemKeys.isEmpty() || entityKeys == null || entityKeys.isEmpty()) {
return;
if (WARNED_EMPTY_REGISTRIES.compareAndSet(false, true)) {
IrisLogging.warn("Content-key validation skipped: platform registries are empty (blocks="
+ size(blockKeys) + " items=" + size(itemKeys) + " entities=" + size(entityKeys) + ")");
}
return ContentKeyIssues.none();
}
ReferencedContentKeys referenced = collectReferencedContentKeys(packFolder);
List<ContentKeyValidator.ContentKeyError> errors = ContentKeyValidator.validate(
registries, referenced.blocks(), referenced.items(), referenced.entities());
for (ContentKeyValidator.ContentKeyError error : errors) {
warnings.add(error.message());
if (referenced.paletteOnlyBlocks().contains(error.key())) {
advisory.add(error.message());
} else {
strict.add(error.message());
}
}
strict.addAll(validateBlockStateProperties(registries, referenced.blockStates()));
} catch (Throwable e) {
IrisLogging.reportError("PackValidator content-key validation failed for pack '" + packFolder.getName() + "'", e);
}
return new ContentKeyIssues(List.copyOf(strict), List.copyOf(advisory));
}
private static int size(List<String> keys) {
return keys == null ? 0 : keys.size();
}
private static ReferencedContentKeys collectReferencedContentKeys(File packFolder) {
Set<String> blocks = new HashSet<>();
Set<String> items = new HashSet<>();
Set<String> entities = new HashSet<>();
Set<String> blockStates = new HashSet<>();
Set<String> customBlocks = deriveRegistrantKeys(new File(packFolder, "blocks"));
try (Stream<Path> stream = Files.walk(packFolder.toPath())) {
@@ -279,48 +485,85 @@ public final class ContentKeyValidator {
} catch (Throwable ignored) {
continue;
}
collectFromNode(json, blocks, inLoot ? items : null, inEntities ? entities : null, customBlocks);
collectFromNode(json, blocks, inLoot ? items : null, inEntities ? entities : null, customBlocks, blockStates);
}
} catch (Throwable e) {
IrisLogging.reportError("PackValidator failed to walk pack for content-key extraction", e);
}
return new ReferencedContentKeys(blocks, items, entities);
Set<String> jsonBlocks = Set.copyOf(blocks);
collectObjectPaletteKeys(new File(packFolder, PackValidator.OBJECTS_FOLDER), blocks, blockStates, customBlocks);
// Keys the JSON scan never saw came only out of an .iob palette. Those stay advisory - the pack author has
// no text to fix.
Set<String> paletteOnly = new HashSet<>(blocks);
paletteOnly.removeAll(jsonBlocks);
return new ReferencedContentKeys(blocks, items, entities, blockStates, paletteOnly);
}
private static void collectFromNode(Object node, Set<String> blocks, Set<String> items, Set<String> entities, Set<String> customBlocks) {
/**
* Adds the V2 {@code .iob} palette keys under {@code objects/} to the referenced block keys. Object palettes are
* the largest source of block keys in a pack and are invisible to the JSON scan.
*/
private static void collectObjectPaletteKeys(File objectsFolder, Set<String> blocks, Set<String> blockStates, Set<String> customBlocks) {
if (!objectsFolder.isDirectory()) {
return;
}
int scanned = 0;
try (Stream<Path> stream = Files.walk(objectsFolder.toPath())) {
List<Path> files = stream.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().endsWith(".iob"))
.toList();
for (Path path : files) {
if (scanned++ >= MAX_OBJECT_PALETTE_SCANS) {
IrisLogging.debug("Content-key validation stopped object palette scan at " + MAX_OBJECT_PALETTE_SCANS
+ " objects (" + files.size() + " present)");
break;
}
for (String key : IrisObjectIO.readPaletteKeys(path.toFile())) {
addBlockRef(key, blocks, customBlocks, blockStates);
}
}
} catch (Throwable e) {
IrisLogging.reportError("PackValidator failed to scan object palettes for content-key extraction", e);
}
}
private static void collectFromNode(Object node, Set<String> blocks, Set<String> items, Set<String> entities, Set<String> customBlocks, Set<String> blockStates) {
if (node instanceof JSONObject obj) {
for (String key : obj.keySet()) {
Object value = obj.get(key);
if (value instanceof String str) {
if ("block".equals(key)) {
addBlockRef(str, blocks, customBlocks);
addBlockRef(str, blocks, customBlocks, blockStates);
} else if (items != null && "type".equals(key)) {
addSimpleRef(str, items);
} else if (entities != null && "type".equals(key)) {
addSimpleRef(str, entities);
}
} else {
collectFromNode(value, blocks, items, entities, customBlocks);
collectFromNode(value, blocks, items, entities, customBlocks, blockStates);
}
}
} else if (node instanceof JSONArray arr) {
for (int i = 0; i < arr.length(); i++) {
collectFromNode(arr.get(i), blocks, items, entities, customBlocks);
collectFromNode(arr.get(i), blocks, items, entities, customBlocks, blockStates);
}
}
}
private static void addBlockRef(String raw, Set<String> blocks, Set<String> customBlocks) {
private static void addBlockRef(String raw, Set<String> blocks, Set<String> customBlocks, Set<String> blockStates) {
String value = raw.trim().toLowerCase(Locale.ROOT);
int bracket = value.indexOf('[');
if (bracket >= 0) {
value = value.substring(0, bracket).trim();
}
if (value.isEmpty() || customBlocks.contains(value)) {
String base = bracket >= 0 ? value.substring(0, bracket).trim() : value;
if (base.isEmpty() || customBlocks.contains(base)) {
return;
}
blocks.add(value);
blocks.add(base);
if (bracket >= 0 && blockStates != null) {
blockStates.add(value);
}
}
private static void addSimpleRef(String raw, Set<String> target) {
@@ -376,6 +619,11 @@ public final class ContentKeyValidator {
return keys;
}
private record ReferencedContentKeys(Set<String> blocks, Set<String> items, Set<String> entities) {
/**
* @param paletteOnlyBlocks the subset of {@code blocks} that no pack JSON names - contributed purely by an
* object palette, so findings about them cannot be blocking
*/
private record ReferencedContentKeys(Set<String> blocks, Set<String> items, Set<String> entities,
Set<String> blockStates, Set<String> paletteOnlyBlocks) {
}
}
@@ -87,7 +87,11 @@ public final class PackValidator {
blockingErrors.addAll(PackSpawnValidator.validateCustomBiomeSpawns(
new File(packFolder, "biomes"), PackSpawnValidator::resolveEntitySpawnCategory));
ContentKeyValidator.runContentKeyValidation(packFolder, warnings);
// Strict content mode promotes unresolved keys and bad block properties from advisory to blocking. Palette
// -sourced findings are exempt and stay warnings - see ContentKeyValidator.collectContentKeyIssues.
ContentKeyValidator.ContentKeyIssues contentKeys = ContentKeyValidator.collectContentKeyIssues(packFolder);
addDistinct(ContentKeyValidator.strictContent() ? blockingErrors : warnings, contentKeys.strict());
addDistinct(warnings, contentKeys.advisory());
return new PackValidationResult(packName, blockingErrors, warnings, validatedAt);
}
@@ -35,6 +35,19 @@ import java.util.Map;
@Builder
@Data
public class PregenTask {
/**
* Saturation limits for block bounds. The full int range is safe downstream: the widest derived value is
* regionToChunk(blockToRegionFloor(MAX_BLOCK)) + 31 shifted back to blocks, which lands inside int.
*/
static final int MIN_BLOCK = Integer.MIN_VALUE;
static final int MAX_BLOCK = Integer.MAX_VALUE;
/**
* Widest region span a pregen may cover on one axis: the Minecraft world limit of +/- 30,000,000 blocks,
* which is 58594 regions each way. Clamping alone is not enough - a saturated bound spans 8.4 million
* regions per axis, and the spiral over that is ~7e13 iterations, which never finishes and looks like a
* hang. A request past the world limit is a bad request, so it fails at construction.
*/
static final int MAX_REGION_SPAN = 117_189;
private static final int MAX_CACHED_ORDERS = 512;
private static final LinkedHashMap<Long, int[]> ORDERS = new LinkedHashMap<>(64, 0.75f, true) {
@Override
@@ -194,11 +207,16 @@ public class PregenTask {
private Bound chunk = null;
private Bound region = null;
/**
* Saturating block bounds. center +/- radius is int arithmetic that wraps for far-out centers or huge
* radii, and a wrapped bound silently inverts min/max so every check() fails and the job pregenerates
* nothing. Clamp in long space instead.
*/
public void update() {
int maxX = center.getX() + radiusX;
int maxZ = center.getZ() + radiusZ;
int minX = center.getX() - radiusX;
int minZ = center.getZ() - radiusZ;
int maxX = clampBlock((long) center.getX() + radiusX);
int maxZ = clampBlock((long) center.getZ() + radiusZ);
int minX = clampBlock((long) center.getX() - radiusX);
int minZ = clampBlock((long) center.getZ() - radiusZ);
chunk = new Bound(
PowerOfTwoCoordinates.blockToChunkFloor(minX),
@@ -212,6 +230,20 @@ public class PregenTask {
PowerOfTwoCoordinates.ceilDivPow2(maxX, PowerOfTwoCoordinates.REGION_BITS),
PowerOfTwoCoordinates.ceilDivPow2(maxZ, PowerOfTwoCoordinates.REGION_BITS)
);
requireSaneSpan(region);
}
/**
* A clamped bound is ordered but can still be absurd. Refuse it here instead of handing the spiral a
* span no run could ever finish.
*/
private void requireSaneSpan(Bound region) {
if (region.sizeX() > MAX_REGION_SPAN || region.sizeZ() > MAX_REGION_SPAN) {
throw new IllegalArgumentException("Pregen area is larger than a Minecraft world: center "
+ center.getX() + "," + center.getZ() + " radius " + radiusX + "x" + radiusZ
+ " blocks spans " + region.sizeX() + "x" + region.sizeZ() + " regions, limit "
+ MAX_REGION_SPAN + ".");
}
}
public Bound chunk() {
@@ -225,6 +257,13 @@ public class PregenTask {
}
}
static int clampBlock(long block) {
if (block > MAX_BLOCK) {
return MAX_BLOCK;
}
return block < MIN_BLOCK ? MIN_BLOCK : (int) block;
}
private record Bound(int minX, int minZ, int maxX, int maxZ, int sizeX, int sizeZ) {
private Bound(int minX, int minZ, int maxX, int maxZ) {
this(minX, minZ, maxX, maxZ, maxX - minX + 1, maxZ - minZ + 1);
@@ -1,147 +0,0 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.project;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.io.IO;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import java.io.File;
import java.io.IOException;
@SuppressWarnings("ALL")
public final class IrisProjectCleaner {
private IrisProjectCleaner() {
}
public static int clean(VolmitSender s, File clean) {
int c = 0;
if (clean.isDirectory()) {
for (File i : clean.listFiles()) {
c += clean(s, i);
}
} else if (clean.getName().endsWith(".json")) {
try {
clean(clean);
} catch (Throwable e) {
IrisLogging.reportError(e);
IrisLogging.error("Failed to beautify " + clean.getAbsolutePath() + " You may have errors in your json!");
}
c++;
}
return c;
}
public static void clean(File clean) throws IOException {
JSONObject obj = new JSONObject(IO.readAll(clean));
fixBlocks(obj, clean);
IO.writeAll(clean, obj.toString(4));
}
public static void fixBlocks(JSONObject obj, File f) {
for (String i : obj.keySet()) {
Object o = obj.get(i);
if (i.equals("block") && o instanceof String && !o.toString().trim().isEmpty() && !o.toString().contains(":")) {
obj.put(i, "minecraft:" + o);
IrisLogging.debug("Updated Block Key: " + o + " to " + obj.getString(i) + " in " + f.getPath());
}
if (o instanceof JSONObject) {
fixBlocks((JSONObject) o, f);
} else if (o instanceof JSONArray) {
fixBlocks((JSONArray) o, f);
}
}
}
public static void fixBlocks(JSONArray obj, File f) {
for (int i = 0; i < obj.length(); i++) {
Object o = obj.get(i);
if (o instanceof JSONObject) {
fixBlocks((JSONObject) o, f);
} else if (o instanceof JSONArray) {
fixBlocks((JSONArray) o, f);
}
}
}
static void fixBlocks(JSONObject obj) {
for (String i : obj.keySet()) {
Object o = obj.get(i);
if (i.equals("block") && o instanceof String && !o.toString().trim().isEmpty() && !o.toString().contains(":")) {
obj.put(i, "minecraft:" + o);
}
if (o instanceof JSONObject) {
fixBlocks((JSONObject) o);
} else if (o instanceof JSONArray) {
fixBlocks((JSONArray) o);
}
}
}
static void fixBlocks(JSONArray obj) {
for (int i = 0; i < obj.length(); i++) {
Object o = obj.get(i);
if (o instanceof JSONObject) {
fixBlocks((JSONObject) o);
} else if (o instanceof JSONArray) {
fixBlocks((JSONArray) o);
}
}
}
public static void files(File clean, KList<File> files) {
if (clean.isDirectory()) {
for (File i : clean.listFiles()) {
files(i, files);
}
} else if (clean.getName().endsWith(".json")) {
try {
files.add(clean);
} catch (Throwable e) {
IrisLogging.reportError(e);
}
}
}
public static void filesObjects(File clean, KList<File> files) {
if (clean.isDirectory()) {
for (File i : clean.listFiles()) {
filesObjects(i, files);
}
} else if (clean.getName().endsWith(".iob")) {
try {
files.add(clean);
} catch (Throwable e) {
IrisLogging.reportError(e);
}
}
}
}
@@ -1,152 +0,0 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.project;
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.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.RuntimeUiMessages;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.jobs.Job;
import art.arcane.iris.util.common.scheduling.jobs.JobCollection;
import art.arcane.iris.util.common.scheduling.jobs.ParallelQueueJob;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.io.IO;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.util.localization.MessageArgument;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import java.io.File;
import java.io.IOException;
@SuppressWarnings("ALL")
public class IrisProjectCompiler {
private final IrisProject project;
public IrisProjectCompiler(IrisProject project) {
this.project = project;
}
public void compile(VolmitSender sender) {
IrisData data = IrisData.get(project.getPath());
KList<Job> jobs = new KList<>();
KList<File> files = new KList<>();
KList<File> objects = new KList<>();
IrisProjectCleaner.files(project.getPath(), files);
IrisProjectCleaner.filesObjects(project.getPath(), objects);
jobs.add(new ParallelQueueJob<File>() {
@Override
public void execute(File f) {
try {
IrisObject o = new IrisObject(0, 0, 0);
o.read(f);
if (o.getBlocks().isEmpty()) {
sender.sendComponent(Component.text(IrisLanguage.plain(
RuntimeUiMessages.COMPILE_IOB_EMPTY,
MessageArgument.untrusted("file", f.getName())
), NamedTextColor.RED)
.hoverEvent(HoverEvent.showText(Component.text(IrisLanguage.plain(
RuntimeUiMessages.COMPILE_IOB_EMPTY_HOVER,
MessageArgument.untrusted("path", f.getPath())
), NamedTextColor.YELLOW))));
}
if (o.getW() == 0 || o.getH() == 0 || o.getD() == 0) {
sender.sendComponent(Component.text(IrisLanguage.plain(
RuntimeUiMessages.COMPILE_IOB_NOT_3D,
MessageArgument.untrusted("file", f.getName())
), NamedTextColor.RED)
.hoverEvent(HoverEvent.showText(Component.text(IrisLanguage.plain(
RuntimeUiMessages.COMPILE_IOB_NOT_3D_HOVER,
MessageArgument.untrusted("path", f.getPath())
), NamedTextColor.YELLOW))));
}
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public String getName() {
return "IOB";
}
}.queue(objects));
jobs.add(new ParallelQueueJob<File>() {
@Override
public void execute(File f) {
try {
JSONObject p = new JSONObject(IO.readAll(f));
IrisProjectCleaner.fixBlocks(p);
scanForErrors(data, f, p, sender);
IO.writeAll(f, p.toString(4));
} catch (Throwable e) {
sender.sendComponent(Component.text(IrisLanguage.plain(
RuntimeUiMessages.COMPILE_JSON_ERROR,
MessageArgument.untrusted("file", f.getName())
), NamedTextColor.RED)
.hoverEvent(HoverEvent.showText(Component.text(IrisLanguage.plain(
RuntimeUiMessages.COMPILE_JSON_ERROR_HOVER,
MessageArgument.untrusted("path", f.getPath()),
MessageArgument.untrusted("error", String.valueOf(e.getMessage()))
), NamedTextColor.YELLOW))));
}
}
@Override
public String getName() {
return "JSON";
}
}.queue(files));
new JobCollection(IrisLanguage.text(RuntimeUiMessages.JOB_COMPILE), jobs).execute(sender);
}
private void scanForErrors(IrisData data, File f, JSONObject p, VolmitSender sender) {
String key = data.toLoadKey(f);
ResourceLoader<?> loader = data.getTypedLoaderFor(f);
if (loader == null) {
sender.sendMessage(IrisLanguage.text(
RuntimeUiMessages.COMPILE_LOADER_NOT_FOUND,
MessageArgument.untrusted("path", f.getPath())
));
return;
}
IrisRegistrant load = loader.load(key);
compare(load.getClass(), p, sender, new KList<>());
load.scanForErrors(p, sender);
}
public void compare(Class<?> c, JSONObject j, VolmitSender sender, KList<String> path) {
try {
Object o = c.getClass().getConstructor().newInstance();
} catch (Throwable e) {
}
}
}
@@ -20,21 +20,18 @@ package art.arcane.iris.core.project;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.PlatformBlockProperty;
import art.arcane.iris.spi.PlatformNumericRange;
import art.arcane.iris.core.link.Identifier;
import art.arcane.iris.core.link.data.DataType;
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.core.service.ExternalDataSVC;
import art.arcane.iris.core.structure.StructureSchemaKeys;
import art.arcane.iris.engine.framework.ListFunction;
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.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;
@@ -63,16 +60,19 @@ import java.lang.reflect.InaccessibleObjectException;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Supplier;
public class SchemaBuilder {
private static final String SYMBOL_LIMIT__N = "*";
private static final String SYMBOL_TYPE__N = "";
private static final String MINECRAFT_NAMESPACE = "minecraft:";
private static final JSONArray FONT_TYPES = new JSONArray(GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames());
private static volatile JSONArray fontTypes;
private final KMap<String, JSONObject> definitions;
private final Class<?> root;
private final KList<String> warnings;
@@ -115,28 +115,140 @@ public class SchemaBuilder {
return schema;
}
/**
* Font families are only used for schema completion. Enumerating them touches AWT, which can fail outright on a
* headless or mod-loader JVM - a failure degrades to no completion, never to a broken schema.
*/
private static JSONArray fontTypes() {
JSONArray cached = fontTypes;
if (cached != null) {
return cached;
}
JSONArray built;
try {
built = new JSONArray(GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames());
} catch (Throwable e) {
IrisLogging.debug("Schema font family enumeration unavailable: " + e.getMessage());
built = new JSONArray();
}
fontTypes = built;
return built;
}
private JSONArray potionTypes() {
if (potionTypes == null) {
JSONArray a = new JSONArray();
for (String key : IrisPlatforms.get().registries().potionEffectKeys()) {
a.put(stripNamespace(key).toUpperCase(Locale.ROOT).replace(" ", "_"));
}
potionTypes = a;
potionTypes = registryKeyForms(IrisPlatforms.get().registries().potionEffectKeys(), true);
}
return potionTypes;
}
private JSONArray enchantTypes() {
if (enchantTypes == null) {
JSONArray a = new JSONArray();
for (String key : IrisPlatforms.get().registries().enchantmentKeys()) {
a.put(stripNamespace(key));
}
enchantTypes = a;
enchantTypes = registryKeyForms(IrisPlatforms.get().registries().enchantmentKeys(), false);
}
return enchantTypes;
}
/**
* Emits the full namespaced key for every registry entry, so mod and datapack content is addressable
* unambiguously, plus the legacy short form for the vanilla namespace so existing packs stay valid.
*/
private static JSONArray registryKeyForms(List<String> keys, boolean upperCaseLegacy) {
JSONArray a = new JSONArray();
Set<String> seen = new LinkedHashSet<>();
if (keys != null) {
for (String key : keys) {
if (key == null || key.isBlank()) {
continue;
}
seen.add(key);
if (key.startsWith(MINECRAFT_NAMESPACE) || key.indexOf(':') < 0) {
String path = stripNamespace(key);
seen.add(upperCaseLegacy ? path.toUpperCase(Locale.ROOT).replace(' ', '_') : path);
}
}
}
for (String key : seen) {
a.put(key);
}
return a;
}
/**
* Biome derivatives resolve through NamespacedKey.fromString, which accepts a full key or a bare vanilla path,
* so both forms are offered.
*/
private JSONArray biomeTypes() {
return registryKeyForms(IrisPlatforms.get().registries().biomeKeys(), false);
}
private JSONArray entityTypes() {
return keysAsArray(IrisPlatforms.get().registries().entityKeys());
}
private JSONArray specialEntityTypes() {
return keysAsArray(IrisPlatforms.get().registries().specialEntityKeys());
}
private JSONArray vanillaStructures() {
return keysAsArray(IrisPlatforms.get().registries().structureKeys());
}
private JSONArray vanillaStructureSets() {
return keysAsArray(IrisPlatforms.get().structureHooks().structureSetKeys());
}
private JSONArray nativeJigsawPools() {
return keysAsArray(templatePoolKeys());
}
private static JSONArray keysAsArray(List<String> keys) {
JSONArray a = new JSONArray();
if (keys != null) {
for (String key : keys) {
if (key != null && !key.isBlank()) {
a.put(key);
}
}
}
return a;
}
/**
* Registers a registry-backed enum definition under {@code definitionKey} and points {@code target} at it.
* <p>
* An empty key list means the registry has nothing to offer yet - the server is still booting, a modded registry
* has not been frozen, or the host simply does not expose that catalog. Emitting {@code "enum": []} in that case
* writes a schema that rejects every value the author could possibly type, turning a missing autocomplete list
* into a pack that reads as broken in the editor. The reference is omitted instead, leaving the field
* unconstrained, and the values are only computed when the definition does not exist yet.
*/
private void putRegistryEnumRef(JSONObject target, String definitionKey, Supplier<JSONArray> values) {
if (!definitions.containsKey(definitionKey)) {
JSONArray built = values.get();
if (built == null || built.length() == 0) {
IrisLogging.debug("Schema enum '" + definitionKey + "' omitted: the registry returned no keys");
return;
}
JSONObject definition = new JSONObject();
definition.put("enum", built);
definitions.put(definitionKey, definition);
}
target.put("$ref", "#/definitions/" + definitionKey);
}
/**
* {@link #putRegistryEnumRef(JSONObject, String, Supplier)} for a list-typed property. When the enum is omitted no
* {@code items} schema is written at all, which is valid and simply means "any element".
*/
private void putRegistryEnumItems(JSONObject prop, String definitionKey, Supplier<JSONArray> values) {
JSONObject items = new JSONObject();
putRegistryEnumRef(items, definitionKey, values);
if (items.has("$ref")) {
prop.put("items", items);
}
}
private JSONArray itemTypes() {
JSONArray a = new JSONArray();
for (String key : IrisPlatforms.get().registries().itemKeys()) {
@@ -304,160 +416,56 @@ public class SchemaBuilder {
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListBlockType.class)) {
String key = "enum-block-type";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", blockTypes());
definitions.put(key, j);
}
fancyType = "Block Type";
prop.put("$ref", "#/definitions/" + key);
putRegistryEnumRef(prop, "enum-block-type", this::blockTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) {
String key = "enum-native-jigsaw-pool";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
JSONArray ja = new JSONArray();
for (String i : templatePoolKeys()) {
ja.put(i);
}
j.put("enum", ja);
definitions.put(key, j);
}
fancyType = "Native Jigsaw Pool";
prop.put("$ref", "#/definitions/" + key);
putRegistryEnumRef(prop, "enum-native-jigsaw-pool", this::nativeJigsawPools);
description.add(SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) {
String key = "enum-vanilla-structure";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
JSONArray ja = new JSONArray();
for (String i : IrisPlatforms.get().registries().structureKeys()) {
ja.put(i);
}
j.put("enum", ja);
definitions.put(key, j);
}
fancyType = "Vanilla Structure";
prop.put("$ref", "#/definitions/" + key);
putRegistryEnumRef(prop, "enum-vanilla-structure", this::vanillaStructures);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) {
String key = "enum-vanilla-structure-set";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
JSONArray ja = new JSONArray();
for (String i : IrisPlatforms.get().structureHooks().structureSetKeys()) {
ja.put(i);
}
j.put("enum", ja);
definitions.put(key, j);
}
fancyType = "Vanilla Structure Set";
prop.put("$ref", "#/definitions/" + key);
putRegistryEnumRef(prop, "enum-vanilla-structure-set", this::vanillaStructureSets);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure SET key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListItemType.class)) {
String key = "enum-item-type";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", itemTypes());
definitions.put(key, j);
}
fancyType = "Item Type";
prop.put("$ref", "#/definitions/" + key);
putRegistryEnumRef(prop, "enum-item-type", this::itemTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListEntityType.class)) {
String key = "enum-entity-type";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
JSONArray ja = new JSONArray();
for (String i : IrisPlatforms.get().registries().entityKeys()) {
ja.put(i);
}
j.put("enum", ja);
definitions.put(key, j);
}
fancyType = "Entity Type";
prop.put("$ref", "#/definitions/" + key);
putRegistryEnumRef(prop, "enum-entity-type", this::entityTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListBiome.class)) {
fancyType = "Biome Type";
putRegistryEnumRef(prop, "enum-biome-type", this::biomeTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListSpecialEntity.class)) {
String key = "enum-reg-specialentity";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
KList<String> list = IrisServices.get(ExternalDataSVC.class)
.getAllIdentifiers(DataType.ENTITY)
.stream()
.map(Identifier::toString)
.collect(KList.collector());
j.put("enum", list.toJSONStringArray());
definitions.put(key, j);
}
fancyType = "Custom Mob Type";
prop.put("$ref", "#/definitions/" + key);
putRegistryEnumRef(prop, "enum-reg-specialentity", this::specialEntityTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Custom Mob Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListFont.class)) {
String key = "enum-font";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", FONT_TYPES);
definitions.put(key, j);
}
fancyType = "Font Family";
prop.put("$ref", "#/definitions/" + key);
putRegistryEnumRef(prop, "enum-font", SchemaBuilder::fontTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListEnchantment.class)) {
String key = "enum-enchantment";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", enchantTypes());
definitions.put(key, j);
}
fancyType = "Enchantment Type";
prop.put("$ref", "#/definitions/" + key);
putRegistryEnumRef(prop, "enum-enchantment", this::enchantTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListPotionEffect.class)) {
String key = "enum-potion-effect-type";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", potionTypes());
definitions.put(key, j);
}
fancyType = "Potion Effect Type";
prop.put("$ref", "#/definitions/" + key);
putRegistryEnumRef(prop, "enum-potion-effect-type", this::potionTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListFunction.class)) {
Class<? extends ListFunction<KList<String>>> functionClass = k.getDeclaredAnnotation(RegistryListFunction.class).value();
@@ -615,147 +623,43 @@ public class SchemaBuilder {
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) {
fancyType = "List<Native Jigsaw Pool>";
String key = "enum-native-jigsaw-pool";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
JSONArray values = new JSONArray();
for (String poolKey : templatePoolKeys()) {
values.put(poolKey);
}
j.put("enum", values);
definitions.put(key, j);
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
putRegistryEnumItems(prop, "enum-native-jigsaw-pool", this::nativeJigsawPools);
description.add(SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) {
fancyType = "List<Vanilla Structure>";
String key = "enum-vanilla-structure";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
JSONArray values = new JSONArray();
for (String structureKey : IrisPlatforms.get().registries().structureKeys()) {
values.put(structureKey);
}
j.put("enum", values);
definitions.put(key, j);
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
putRegistryEnumItems(prop, "enum-vanilla-structure", this::vanillaStructures);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) {
fancyType = "List<Vanilla Structure Set>";
String key = "enum-vanilla-structure-set";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
JSONArray values = new JSONArray();
for (String structureSetKey : IrisPlatforms.get().structureHooks().structureSetKeys()) {
values.put(structureSetKey);
}
j.put("enum", values);
definitions.put(key, j);
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
putRegistryEnumItems(prop, "enum-vanilla-structure-set", this::vanillaStructureSets);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure set key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListBlockType.class)) {
fancyType = "List of Block Types";
String key = "enum-block-type";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", blockTypes());
definitions.put(key, j);
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
putRegistryEnumItems(prop, "enum-block-type", this::blockTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListItemType.class)) {
fancyType = "List of Item Types";
String key = "enum-item-type";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", itemTypes());
definitions.put(key, j);
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
putRegistryEnumItems(prop, "enum-item-type", this::itemTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListEntityType.class)) {
fancyType = "List of Entity Types";
String key = "enum-entity-type";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
JSONArray ja = new JSONArray();
for (String i : IrisPlatforms.get().registries().entityKeys()) {
ja.put(i);
}
j.put("enum", ja);
definitions.put(key, j);
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
putRegistryEnumItems(prop, "enum-entity-type", this::entityTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListBiome.class)) {
fancyType = "List of Biome Types";
putRegistryEnumItems(prop, "enum-biome-type", this::biomeTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListFont.class)) {
String key = "enum-font";
fancyType = "List of Font Families";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", FONT_TYPES);
definitions.put(key, j);
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
putRegistryEnumItems(prop, "enum-font", SchemaBuilder::fontTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListEnchantment.class)) {
fancyType = "List of Enchantment Types";
String key = "enum-enchantment";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", enchantTypes());
definitions.put(key, j);
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
putRegistryEnumItems(prop, "enum-enchantment", this::enchantTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListPotionEffect.class)) {
fancyType = "List of Potion Effect Types";
String key = "enum-potion-effect-type";
if (!definitions.containsKey(key)) {
JSONObject j = new JSONObject();
j.put("enum", potionTypes());
definitions.put(key, j);
}
JSONObject items = new JSONObject();
items.put("$ref", "#/definitions/" + key);
prop.put("items", items);
putRegistryEnumItems(prop, "enum-potion-effect-type", this::potionTypes);
description.add(SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)");
} else if (k.isAnnotationPresent(RegistryListFunction.class)) {
Class<? extends ListFunction<KList<String>>> functionClass = k.getDeclaredAnnotation(RegistryListFunction.class).value();
@@ -41,6 +41,8 @@ public final class IrisProtocolServer {
private final AtomicLong capabilityRejected;
private final AtomicLong noEngineDrops;
private final AtomicLong cursorInfoServed;
private final AtomicLong cursorRateLimited;
private final AtomicLong cursorOutOfBounds;
private final AtomicLong visionTileForwarded;
private final AtomicLong visionRateLimited;
private final AtomicLong pregenRegionDeltasBroadcast;
@@ -67,6 +69,8 @@ public final class IrisProtocolServer {
this.capabilityRejected = new AtomicLong(0L);
this.noEngineDrops = new AtomicLong(0L);
this.cursorInfoServed = new AtomicLong(0L);
this.cursorRateLimited = new AtomicLong(0L);
this.cursorOutOfBounds = new AtomicLong(0L);
this.visionTileForwarded = new AtomicLong(0L);
this.visionRateLimited = new AtomicLong(0L);
this.pregenRegionDeltasBroadcast = new AtomicLong(0L);
@@ -259,6 +263,14 @@ public final class IrisProtocolServer {
return cursorInfoServed.get();
}
public long cursorRateLimitedCount() {
return cursorRateLimited.get();
}
public long cursorOutOfBoundsCount() {
return cursorOutOfBounds.get();
}
public long visionTileForwardedCount() {
return visionTileForwarded.get();
}
@@ -303,6 +315,10 @@ public final class IrisProtocolServer {
private void onClientHello(IrisSession session, IrisMessage.ClientHello clientHello) {
if (clientHello.protocolVersion() != IrisProtocol.PROTOCOL_VERSION) {
versionMismatches.incrementAndGet();
// Answer anyway with our version so the client lands in INCOMPATIBLE instead of retrying until it
// gives up and reports "server does not run Iris". The session stays AWAITING_HELLO and every
// later frame from it is dropped by dispatch.
session.send(new IrisMessage.ServerHello(IrisProtocol.PROTOCOL_VERSION, serverCapabilities, serverBrand, irisActive));
return;
}
session.markReady(clientHello.protocolVersion(), clientHello.capabilities());
@@ -314,6 +330,14 @@ public final class IrisProtocolServer {
capabilityRejected.incrementAndGet();
return;
}
if (outOfWorldBounds(request.blockX()) || outOfWorldBounds(request.blockZ())) {
cursorOutOfBounds.incrementAndGet();
return;
}
if (!session.allowCursorInfo(clock.getAsLong())) {
cursorRateLimited.incrementAndGet();
return;
}
EngineResolver resolver = engineResolver;
if (resolver == null) {
noEngineDrops.incrementAndGet();
@@ -345,4 +369,8 @@ public final class IrisProtocolServer {
handler.handle(session.id(), request.tileX(), request.tileZ(), request.zoomLevel());
visionTileForwarded.incrementAndGet();
}
private static boolean outOfWorldBounds(int coordinate) {
return coordinate > IrisProtocol.MAX_QUERY_BLOCK_COORDINATE || coordinate < -IrisProtocol.MAX_QUERY_BLOCK_COORDINATE;
}
}
@@ -34,6 +34,8 @@ public final class IrisSession {
private int inboundFramesInWindow;
private long visionTileWindowStartMillis;
private int visionTileRequestsInWindow;
private long cursorInfoWindowStartMillis;
private int cursorInfoRequestsInWindow;
public IrisSession(String id, IrisServerTransport transport) {
this.id = Objects.requireNonNull(id, "session id");
@@ -45,6 +47,8 @@ public final class IrisSession {
this.inboundFramesInWindow = 0;
this.visionTileWindowStartMillis = 0L;
this.visionTileRequestsInWindow = 0;
this.cursorInfoWindowStartMillis = 0L;
this.cursorInfoRequestsInWindow = 0;
}
public String id() {
@@ -101,6 +105,23 @@ public final class IrisSession {
return true;
}
/**
* Cursor lookups get their own second-window budget instead of sharing
* {@link #allowInbound(long)}: a client is free to spend its whole frame budget on cursors otherwise, and
* each lookup costs three engine column resolves.
*/
public synchronized boolean allowCursorInfo(long nowMillis) {
if (nowMillis - cursorInfoWindowStartMillis >= 1000L) {
cursorInfoWindowStartMillis = nowMillis;
cursorInfoRequestsInWindow = 0;
}
if (cursorInfoRequestsInWindow >= IrisProtocol.MAX_CURSOR_INFO_REQUESTS_PER_SECOND) {
return false;
}
cursorInfoRequestsInWindow++;
return true;
}
public void send(IrisMessage message) {
sendRaw(IrisMessageCodec.encode(message));
}
@@ -20,6 +20,7 @@ package art.arcane.iris.core.protocol;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.PreservationRegistry;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.protocol.IrisMessage;
import art.arcane.iris.spi.protocol.IrisProtocol;
@@ -37,13 +38,21 @@ import java.util.concurrent.atomic.AtomicLong;
public final class IrisVisionRequestService implements VisionTileRequestHandler {
private static final int DEFAULT_MAX_PENDING = 64;
private static final long SHED_LOG_INTERVAL_MILLIS = 60_000L;
private static final int SEQUENCE_WRAP_GUARD = Integer.MAX_VALUE - 1024;
private final EngineResolver engineResolver;
private final IrisSessionRegistry registry;
private final Executor executor;
private final int maxPending;
private final ArrayDeque<PendingRequest> pending;
/**
* One counter per session, not one per (session, tile, zoom). The client only ever compares sequences
* within a single tile key, so a session-wide monotonic counter satisfies the "newer wins" contract in
* IrisTileAssembler while keeping this map bounded by the player count instead of by how far players pan.
*/
private final ConcurrentHashMap<String, Integer> sequences;
private final AtomicLong nextShedLogAt;
private final AtomicLong droppedSaturated;
private final AtomicLong droppedNoEngine;
private final AtomicLong droppedNoSession;
@@ -56,6 +65,7 @@ public final class IrisVisionRequestService implements VisionTileRequestHandler
this.maxPending = Math.max(1, maxPending);
this.pending = new ArrayDeque<>();
this.sequences = new ConcurrentHashMap<>();
this.nextShedLogAt = new AtomicLong(0L);
this.droppedSaturated = new AtomicLong(0L);
this.droppedNoEngine = new AtomicLong(0L);
this.droppedNoSession = new AtomicLong(0L);
@@ -82,16 +92,35 @@ public final class IrisVisionRequestService implements VisionTileRequestHandler
@Override
public void handle(String sessionId, int tileX, int tileZ, int zoomLevel) {
PendingRequest request = new PendingRequest(sessionId, tileX, tileZ, zoomLevel);
int shed = 0;
synchronized (pending) {
while (pending.size() >= maxPending) {
pending.pollFirst();
droppedSaturated.incrementAndGet();
shed++;
}
pending.addLast(request);
}
if (shed > 0) {
droppedSaturated.addAndGet(shed);
logShed();
}
executor.execute(this::drainOne);
}
/**
* Drops the retained sequence counter and every queued tile request for a session. Called when a session
* disconnects or unregisters so neither structure grows with the player count over a server's uptime.
*/
public void clearSession(String sessionId) {
if (sessionId == null || sessionId.isEmpty()) {
return;
}
sequences.remove(sessionId);
synchronized (pending) {
pending.removeIf((PendingRequest request) -> sessionId.equals(request.sessionId()));
}
}
public long droppedSaturatedCount() {
return droppedSaturated.get();
}
@@ -145,8 +174,19 @@ public final class IrisVisionRequestService implements VisionTileRequestHandler
}
private int nextSequence(PendingRequest request) {
String key = request.sessionId() + ":" + request.tileX() + ":" + request.tileZ() + ":" + request.zoomLevel();
return sequences.merge(key, 1, Integer::sum);
return sequences.merge(
request.sessionId(),
1,
(Integer current, Integer step) -> current >= SEQUENCE_WRAP_GUARD ? 1 : current + step);
}
private void logShed() {
long now = System.currentTimeMillis();
long due = nextShedLogAt.get();
if (now < due || !nextShedLogAt.compareAndSet(due, now + SHED_LOG_INTERVAL_MILLIS)) {
return;
}
IrisLogging.warn("vision: request queue saturated at " + maxPending + ", shed " + droppedSaturated.get() + " total");
}
private record PendingRequest(String sessionId, int tileX, int tileZ, int zoomLevel) {
@@ -178,17 +178,27 @@ final class EngineHotloader {
}
}
/**
* Never throws. It is called from the hotload failure handler, where a frame-cap
* {@code IllegalStateException} out of the codec (a long pack key or exception message overruns
* MAX_FRAME_BYTES) would otherwise propagate in place of the real hotload error and lose it.
*/
private void broadcastStudioHotload(boolean failed, String message) {
IrisProtocolServer protocolServer = IrisServices.getOrNull(IrisProtocolServer.class);
if (protocolServer == null) {
return;
try {
IrisProtocolServer protocolServer = IrisServices.getOrNull(IrisProtocolServer.class);
if (protocolServer == null) {
return;
}
IrisDimension dimension = engine.getDimension();
String packKey = dimension == null ? "" : dimension.getLoadKey();
protocolServer.broadcastStudioHotload(packKey, 0, failed, message);
protocolServer.broadcastToast(
failed ? IrisMessage.Toast.KIND_ERROR : IrisMessage.Toast.KIND_SUCCESS,
IrisLanguage.plain(ClientUiMessages.TOAST_STUDIO_HOTLOAD),
failed ? IrisLanguage.plain(ClientUiMessages.TOAST_PACK_FAILED, MessageArgument.untrusted("pack", packKey)) : packKey);
} catch (Throwable broadcastFailure) {
IrisLogging.error("Iris studio hotload broadcast failed: " + broadcastFailure.getClass().getSimpleName()
+ ": " + broadcastFailure.getMessage());
}
IrisDimension dimension = engine.getDimension();
String packKey = dimension == null ? "" : dimension.getLoadKey();
protocolServer.broadcastStudioHotload(packKey, 0, failed, message);
protocolServer.broadcastToast(
failed ? IrisMessage.Toast.KIND_ERROR : IrisMessage.Toast.KIND_SUCCESS,
IrisLanguage.plain(ClientUiMessages.TOAST_STUDIO_HOTLOAD),
failed ? IrisLanguage.plain(ClientUiMessages.TOAST_PACK_FAILED, MessageArgument.untrusted("pack", packKey)) : packKey);
}
}
@@ -134,6 +134,24 @@ public class IrisEngine implements Engine {
private final AtomicBoolean modeFallbackLogged;
private final AtomicBoolean prefetchSaveStarted;
/**
* Object identity, not value identity. An engine is a live mutable service, and {@code @Data} would otherwise
* generate equals/hashCode over every field above - counters, latches, rolling averages - so an engine's hash
* would change on every generated chunk and its equality would depend on transient timing state.
* <p>
* Three live maps key on an engine: the modded GUI host registry and the two WeakHashMap tree-feller indexes. A
* mutating hash silently loses their entries, and a value-based equals lets two distinct engines collide.
*/
@Override
public boolean equals(Object o) {
return this == o;
}
@Override
public int hashCode() {
return System.identityHashCode(this);
}
public IrisEngine(EngineTarget target, boolean studio) {
this.studio = studio;
this.target = target;
@@ -0,0 +1,92 @@
/*
* Iris is a World Generator for Minecraft 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.engine.object.IrisDecorationStep;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisImportedFeatureControl;
import art.arcane.iris.engine.object.NativeFeatureGenerationStatus;
import java.util.Objects;
/**
* Single decision point for native placed-feature generation, mirroring {@link NativeStructureGenerationPolicy}.
* Both platform generators call only through here so the Bukkit and modded feature passes cannot drift.
*/
public final class NativeFeatureGenerationPolicy {
/**
* Stand-in for a dimension that has no control block. Immutable in practice: nothing here hands it out for
* mutation, and every default in {@link IrisImportedFeatureControl} is "off".
*/
private static final IrisImportedFeatureControl DISABLED = new IrisImportedFeatureControl();
private NativeFeatureGenerationPolicy() {
}
/**
* The dimension's control block, or a disabled default when it has none.
* <p>
* An absent {@code importedFeatures} member deserializes to the field's initializer, but an explicit
* {@code "importedFeatures": null} in dimension JSON overwrites that initializer with null - Gson assigns what
* the document says. This runs on the generation path for every feature decision, so throwing there turns one
* stray null in a pack into a failed chunk rather than a dimension that simply generates no native features.
*/
public static IrisImportedFeatureControl control(Engine engine) {
Engine activeEngine = Objects.requireNonNull(engine, "Native feature policy requires an engine");
IrisDimension dimension = Objects.requireNonNull(activeEngine.getDimension(),
"Native feature policy requires a bound dimension");
IrisImportedFeatureControl control = dimension.getImportedFeatures();
return control == null ? DISABLED : control;
}
/**
* True when this dimension opted into the native feature pass. Checked before any feature machinery is
* built so a disabled dimension never allocates a feature table.
*/
public static boolean isEnabled(Engine engine) {
return control(engine).shouldGenerateFeatures();
}
public static NativeFeatureGenerationStatus resolve(Engine engine, String placedFeatureKey,
IrisDecorationStep step) {
return control(engine).resolve(placedFeatureKey, step);
}
public static boolean shouldGenerateStep(Engine engine, IrisDecorationStep step) {
return control(engine).shouldGenerateStep(step);
}
public static String generationStatusMessage(String placedFeatureKey,
NativeFeatureGenerationStatus status) {
String key = placedFeatureKey == null ? "" : placedFeatureKey.trim();
return switch (Objects.requireNonNull(status, "Native feature status must not be null")) {
case GENERATE_NATIVE -> "Native feature " + key + " generates natively.";
case FEATURES_DISABLED -> "Native feature " + key
+ " does not generate because this dimension's importedFeatures.enabled is false.";
case STEP_DISABLED -> "Native feature " + key
+ " does not generate because its decoration step is excluded by importedFeatures.";
case DISABLED_BY_PACK -> "Native feature " + key
+ " is disabled by this dimension's importedFeatures.disabled list.";
case CYCLE_DEGRADED -> "Native feature " + key
+ " does not generate because the registered features could not be ordered "
+ "(feature order cycle); importedFeatures was degraded to off for this dimension.";
case INVALID_REGISTRY_KEY -> "Native feature registry key is invalid: " + key;
};
}
}
@@ -29,16 +29,15 @@ import art.arcane.iris.engine.object.annotations.DependsOn;
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.engine.object.annotations.RegistryListBiome;
import art.arcane.iris.engine.object.annotations.RegistryListResource;
import art.arcane.iris.engine.object.annotations.Required;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap;
import art.arcane.volmlib.util.collection.KSet;
import art.arcane.iris.util.common.data.DataProvider;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.project.noise.CNG;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.project.context.IrisContext;
import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
import lombok.AccessLevel;
@@ -139,15 +138,19 @@ public class IrisBiome extends IrisRegistrant implements IRare {
@Desc("A color for visualizing this biome with a color. I.e. #F13AF5. This will show up on the map.")
private String color = null;
@Required
@RegistryListBiome
@Desc("The raw derivative of this biome. This is required or the terrain will not properly generate. Use any vanilla biome type. Look in examples/biome-list.txt")
private String derivative = "minecraft:the_void";
@Required
@RegistryListBiome
@Desc("Override the derivative used for vanilla structure selection. Iris still enforces the generated terrain role: land-only Minecraft derivatives on sea biomes expose no native structure biome, and land-only derivatives on shore biomes resolve as beach, while exact ocean, river, beach, and shore variants remain eligible. Non-Minecraft namespaces remain authoritative. Not defining this value selects derivative.")
private String vanillaDerivative = null;
@ArrayType(min = 1, type = String.class)
@RegistryListBiome
@Desc("You can instead specify multiple biome derivatives to randomly scatter colors in this biome")
private KList<String> biomeScatter = new KList<>();
@ArrayType(min = 1, type = String.class)
@RegistryListBiome
@Desc("Since 1.13 supports 3D biomes, you can add different derivative colors for anything above the terrain. (Think swampy tree leaves with a desert looking grass surface)")
private KList<String> biomeSkyScatter = new KList<>();
@DependsOn({"children"})
@@ -625,11 +628,6 @@ public class IrisBiome extends IrisRegistrant implements IRare {
return "Biome";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
private static final class SeededBiomeGenerator {
private final long seed;
private final CNG generator;
@@ -102,6 +102,35 @@ public class IrisBiomeCustom {
@Desc("The color of foliage (hex format). Leave blank / don't define to not change")
private String foliageColor = "";
/**
* The tags this custom biome is installed into: the pack author's own {@code tags} plus the direct tag
* membership of the Iris biome's vanilla derivative. Inheriting the derivative's tags is what makes
* {@code #minecraft:is_overworld} and every mod-authored tag selector resolve against Iris custom biomes;
* without it a custom biome sits in no tag at all and mod content that gates on tags silently never runs.
*
* <p>Author tags win order but duplicates collapse: the tag files are written through a sorted set.
* Structure tags ({@code has_structure/*}) are deliberately not inherited - native structure placement
* resolves through the biome's structure derivative, so injecting them would place a structure twice.
*
* @param vanillaDerivativeKey the owning Iris biome's vanilla derivative key, may be null
*/
public KList<String> getEffectiveTags(String vanillaDerivativeKey) {
KList<String> resolved = new KList<>();
KList<String> authored = getTags();
if (authored != null) {
for (String tag : authored) {
resolved.addIfMissing(tag);
}
}
for (String inherited : IrisVanillaBiomeTags.tagsFor(vanillaDerivativeKey)) {
resolved.addIfMissing(inherited);
}
return resolved;
}
public String generateJson(IDataFixer fixer) {
JSONObject effects = new JSONObject();
effects.put("sky_color", parseColor(getSkyColor()));
@@ -110,12 +139,19 @@ public class IrisBiomeCustom {
effects.put("water_fog_color", parseColor(getWaterFogColor()));
if (ambientParticle != null) {
JSONObject particle = new JSONObject();
JSONObject po = new JSONObject();
po.put("type", ambientParticle.getParticle().name().toLowerCase());
particle.put("options", po);
particle.put("probability", 1f/ambientParticle.getRarity());
effects.put("particle", particle);
// Never touch IrisBiomeCustomParticle.getParticle() here: it returns a Bukkit Particle
// and this method also runs during modded datapack staging.
String particleKey = ambientParticle.getParticleKey();
if (particleKey == null) {
IrisLogging.warn("Custom biome " + getId() + " declares an ambientParticle with no particle key, skipping it");
} else {
JSONObject particle = new JSONObject();
JSONObject po = new JSONObject();
po.put("type", particleKey);
particle.put("options", po);
particle.put("probability", 1f / ambientParticle.getRarity());
effects.put("particle", particle);
}
}
if (!getGrassColor().isEmpty()) {
@@ -49,6 +49,17 @@ public class IrisBiomeCustomParticle {
@Desc("The rarity")
private int rarity = 35;
/**
* The authored particle key, normalized to a namespaced key. Platform-neutral: datapack
* emission must use this and never {@link #getParticle()}, which only resolves on Bukkit.
*/
public String getParticleKey() {
if (particle == null || particle.isEmpty()) {
return null;
}
return particle.indexOf(':') >= 0 ? particle : "minecraft:" + particle;
}
public Particle getParticle() {
return particleResolved.aquire(() -> {
NamespacedKey namespacedKey = NamespacedKey.fromString(particle);
@@ -22,6 +22,7 @@ import art.arcane.iris.engine.data.cache.AtomicCache;
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.engine.object.annotations.RegistryListEntityType;
import art.arcane.iris.engine.object.annotations.Required;
import art.arcane.iris.engine.object.annotations.Snippet;
import art.arcane.iris.util.common.data.registry.RegistryUtil;
@@ -43,6 +44,7 @@ import java.util.Locale;
public class IrisBiomeCustomSpawn {
private final transient AtomicCache<EntityType> typeResolved = new AtomicCache<>();
@Required
@RegistryListEntityType
@Desc("The biome's entity type")
private String type = "minecraft:cow";
@@ -34,8 +34,6 @@ import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap;
import art.arcane.iris.util.common.data.B;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.iris.util.common.plugin.VolmitSender;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -235,9 +233,4 @@ public class IrisBlockData extends IrisRegistrant {
public String getTypeName() {
return "Block";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -0,0 +1,96 @@
/*
* Iris is a World Generator for Minecraft 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.engine.object.annotations.Desc;
import java.util.Locale;
/**
* Pure-JVM mirror of Minecraft's decoration generation steps, in registry (ordinal) order. The ordinals and
* serialized names match {@code GenerationStep.Decoration} on MC 26.2 so a platform can convert either way
* without core depending on a Minecraft type. Verified against MC 26.2 GenerationStep.Decoration.
*/
@Desc("A vanilla decoration generation step. Placed features are grouped into these steps and run in this order.")
public enum IrisDecorationStep {
@Desc("Raw generation - the earliest step, before lakes.")
RAW_GENERATION("raw_generation"),
@Desc("Lakes.")
LAKES("lakes"),
@Desc("Local modifications such as amethyst geodes, icebergs and dripstone clusters.")
LOCAL_MODIFICATIONS("local_modifications"),
@Desc("Underground structure features (not the structure system - features tagged as underground structures).")
UNDERGROUND_STRUCTURES("underground_structures"),
@Desc("Surface structure features.")
SURFACE_STRUCTURES("surface_structures"),
@Desc("Stronghold step.")
STRONGHOLDS("strongholds"),
@Desc("Underground ores. This is the step that carries vanilla and mod ore veins.")
UNDERGROUND_ORES("underground_ores"),
@Desc("Underground decoration such as glow lichen, sculk patches and cave vegetation.")
UNDERGROUND_DECORATION("underground_decoration"),
@Desc("Fluid springs - the small water and lava spring features.")
FLUID_SPRINGS("fluid_springs"),
@Desc("Vegetal decoration - trees, grass, flowers, kelp and most surface plant life.")
VEGETAL_DECORATION("vegetal_decoration"),
@Desc("Top layer modification - freezing and snow placement.")
TOP_LAYER_MODIFICATION("top_layer_modification");
private final String serializedName;
IrisDecorationStep(String serializedName) {
this.serializedName = serializedName;
}
public String getSerializedName() {
return serializedName;
}
/**
* Resolves a step by its vanilla ordinal. Returns null when the running Minecraft version declares more
* decoration steps than this enum knows about, which the callers treat as "unknown step, generate it".
*/
public static IrisDecorationStep byOrdinal(int ordinal) {
IrisDecorationStep[] values = values();
return ordinal < 0 || ordinal >= values.length ? null : values[ordinal];
}
public static IrisDecorationStep byKey(String key) {
if (key == null || key.isBlank()) {
return null;
}
String normalized = key.trim().toLowerCase(Locale.ROOT);
for (IrisDecorationStep step : values()) {
if (step.serializedName.equals(normalized) || step.name().toLowerCase(Locale.ROOT).equals(normalized)) {
return step;
}
}
return null;
}
}
@@ -45,7 +45,6 @@ import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.math.Position2;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.project.noise.CNG;
import art.arcane.iris.util.common.plugin.VolmitSender;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@@ -272,6 +271,8 @@ public class IrisDimension extends IrisRegistrant {
private KList<IrisStructurePlacement> structures = new KList<>();
@Desc("Controls native vanilla, mod, and ingested datapack structure generation for this dimension. Every registered structure is enabled by default; 'disabled' is the sole generation deny list and autocompletes live structure keys.")
private IrisImportedStructureControl importedStructures = new IrisImportedStructureControl();
@Desc("Controls native vanilla, mod, and ingested datapack PLACED FEATURE generation (ores, trees, plants, springs, geodes) for this dimension. Disabled by default: leaving this out generates exactly the terrain Iris always has. Set 'enabled' true to run the vanilla decoration feature pass over Iris terrain. Carvers are never imported.")
private IrisImportedFeatureControl importedFeatures = new IrisImportedFeatureControl();
@ArrayType(type = String.class, min = 1)
@Desc("External datapack sources for this dimension. List Modrinth datapack page URLs or direct zip URLs. Any registered datapack structure can be placed directly through nativeStructures without conversion. Replacing native generation requires a dimension-level structure placement with nativeSuppression set to REPLACE_SOURCE; provenance alone never disables native structures.")
private KList<String> datapackImports = new KList<>();
@@ -535,11 +536,22 @@ public class IrisDimension extends IrisRegistrant {
public void installBiomes(IDataFixer fixer, DataProvider data, KList<File> datapackRoots,
String namespace, String pathPrefix, KSet<String> biomes) throws IOException {
// Tag membership is accumulated in memory and flushed once per tag at the end of the walk. Writing a
// tag file per (biome, tag) pair made staging quadratic: every write re-read, re-parsed and re-emitted
// a file that grows with every biome added to that tag.
KMap<String, KSet<String>> tagMembership = new KMap<>();
for (IrisBiome irisBiome : getAllBiomes(data)) {
if (!irisBiome.isCustom()) {
continue;
}
// Tag membership is inherited from the biome's vanilla derivative so that #minecraft:is_overworld
// style selectors (vanilla's own, and every mod that writes them) hit Iris custom biomes. The
// features and carvers arrays in the emitted biome JSON stay empty: native feature passthrough
// comes from the chunk generator's generation-settings getter, not from the datapack.
String derivativeKey = irisBiome.getVanillaDerivativeKey();
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
String customBiomeId = customBiome.getId();
String json = customBiome.generateJson(fixer);
@@ -551,19 +563,24 @@ public class IrisDimension extends IrisRegistrant {
}
}
String biomePath = pathPrefix.isBlank()
? customBiomeId
: pathPrefix + "/" + customBiomeId;
for (File datapackRoot : datapackRoots) {
String biomePath = pathPrefix.isBlank()
? customBiomeId
: pathPrefix + "/" + customBiomeId;
File output = new File(datapackRoot, "data/" + namespace + "/worldgen/biome/" + biomePath + ".json");
IrisLogging.debug(" Installing Data Pack Biome: " + output.getPath());
output.getParentFile().mkdirs();
IO.writeAll(output, json);
installBiomeTags(datapackRoot, namespace + ":" + biomePath, customBiome.getTags());
}
collectBiomeTags(tagMembership, namespace + ":" + biomePath,
customBiome.getEffectiveTags(derivativeKey));
}
}
for (File datapackRoot : datapackRoots) {
installBiomeTags(datapackRoot, tagMembership);
}
}
public static void clearGeneratedBiomeTags(KList<File> datapackRoots) {
@@ -579,7 +596,11 @@ public class IrisDimension extends IrisRegistrant {
}
}
static void installBiomeTags(File datapackRoot, String biomeKey, KList<String> tags) throws IOException {
/**
* Accumulates one biome's tag membership into the tag-to-biomes map. Normalization and rejection happen
* here so a malformed author tag is reported once, against the biome that declared it.
*/
static void collectBiomeTags(KMap<String, KSet<String>> tagMembership, String biomeKey, KList<String> tags) {
if (tags == null || tags.isEmpty()) {
return;
}
@@ -589,6 +610,20 @@ public class IrisDimension extends IrisRegistrant {
IrisLogging.error("Invalid custom biome tag '" + rawTag + "' for " + biomeKey);
continue;
}
tagMembership.computeIfAbsent(tag, ignored -> new KSet<>()).add(biomeKey);
}
}
/**
* Writes every accumulated tag exactly once into one datapack root, merging with whatever a previous
* dimension or pack already wrote to the same file.
*/
static void installBiomeTags(File datapackRoot, KMap<String, KSet<String>> tagMembership) throws IOException {
if (tagMembership == null || tagMembership.isEmpty()) {
return;
}
for (Map.Entry<String, KSet<String>> entry : tagMembership.entrySet()) {
String tag = entry.getKey();
int separator = tag.indexOf(':');
String namespace = tag.substring(0, separator);
String path = tag.substring(separator + 1);
@@ -596,10 +631,10 @@ public class IrisDimension extends IrisRegistrant {
.toAbsolutePath().normalize();
Path output = tagRoot.resolve(path + ".json").normalize();
if (!output.startsWith(tagRoot)) {
IrisLogging.error("Unsafe custom biome tag '" + rawTag + "' for " + biomeKey);
IrisLogging.error("Unsafe custom biome tag '" + tag + "' for " + entry.getValue());
continue;
}
writeBiomeTag(output, biomeKey);
writeBiomeTag(output, entry.getValue());
}
}
@@ -614,7 +649,10 @@ public class IrisDimension extends IrisRegistrant {
return RESOURCE_KEY_PATTERN.matcher(normalized).matches() ? normalized : null;
}
static void writeBiomeTag(Path output, String biomeKey) throws IOException {
static void writeBiomeTag(Path output, Set<String> biomeKeys) throws IOException {
if (biomeKeys == null || biomeKeys.isEmpty()) {
return;
}
synchronized (IrisDimension.class) {
Set<String> values = new TreeSet<>();
if (Files.isRegularFile(output)) {
@@ -629,7 +667,7 @@ public class IrisDimension extends IrisRegistrant {
}
}
}
values.add(biomeKey);
values.addAll(biomeKeys);
JSONArray outputValues = new JSONArray();
for (String value : values) {
@@ -722,11 +760,6 @@ public class IrisDimension extends IrisRegistrant {
return "Dimension";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
public static void writeShared(
KList<File> datapackRoots,
DimensionHeight height,
@@ -37,6 +37,8 @@ import org.bukkit.enchantments.Enchantment;
import org.bukkit.inventory.meta.EnchantmentStorageMeta;
import org.bukkit.inventory.meta.ItemMeta;
import java.util.Locale;
@Snippet("enchantment")
@Accessors(chain = true)
@@ -65,7 +67,7 @@ public class IrisEnchantment {
public void apply(RNG rng, ItemMeta meta) {
try {
Enchantment enchant = Registry.ENCHANTMENT.get(NamespacedKey.minecraft(getEnchantment()));
Enchantment enchant = resolve();
if (enchant == null) {
IrisLogging.warn("Unknown Enchantment: " + getEnchantment());
return;
@@ -83,6 +85,20 @@ public class IrisEnchantment {
}
}
/**
* Resolves the authored key against the live enchantment registry. Accepts a bare path
* ({@code sharpness}) or a full namespaced key ({@code mymod:vorpal}) - parity with the modded resolver.
*/
private Enchantment resolve() {
String raw = getEnchantment();
if (raw == null || raw.isBlank()) {
return null;
}
String value = raw.trim().toLowerCase(Locale.ROOT).replace(' ', '_');
NamespacedKey key = value.indexOf(':') >= 0 ? NamespacedKey.fromString(value) : NamespacedKey.minecraft(value);
return key == null ? null : Registry.ENCHANTMENT.get(key);
}
public int getLevel(RNG rng) {
return LootResolver.inclusive(rng, getMinLevel(), getMaxLevel());
}
@@ -38,11 +38,9 @@ import art.arcane.iris.platform.bukkit.BukkitWorld;
import art.arcane.iris.spi.PlatformWorld;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.util.common.format.C;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.util.math.M;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.common.plugin.Chunks;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.scheduling.PrecisionStopwatch;
import lombok.Data;
@@ -546,9 +544,4 @@ public class IrisEntity extends IrisRegistrant {
public String getTypeName() {
return "Entity";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -29,9 +29,7 @@ import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.Required;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.project.stream.ProceduralStream;
import art.arcane.iris.util.project.stream.interpolation.Interpolated;
import lombok.AllArgsConstructor;
@@ -139,9 +137,4 @@ public class IrisExpression extends IrisRegistrant {
public String getTypeName() {
return "Expression";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -27,10 +27,8 @@ import art.arcane.iris.engine.object.annotations.MinNumber;
import art.arcane.iris.engine.object.annotations.Required;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.util.project.interpolation.IrisInterpolation;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.project.noise.CellGenerator;
import art.arcane.iris.util.common.plugin.VolmitSender;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -255,9 +253,4 @@ public class IrisGenerator extends IrisRegistrant {
public String getTypeName() {
return "Generator";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -20,8 +20,6 @@ package art.arcane.iris.engine.object;
import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.iris.util.common.plugin.VolmitSender;
import javax.imageio.ImageIO;
import java.awt.Color;
@@ -118,11 +116,6 @@ public class IrisImage extends IrisRegistrant {
return "Image";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
public void writeDebug(IrisImageChannel channel) {
@@ -0,0 +1,106 @@
/*
* Iris is a World Generator for Minecraft 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.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.volmlib.util.collection.KList;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.util.Objects;
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
@Desc("Controls native vanilla, mod, and ingested datapack PLACED FEATURE generation for this dimension (set as the dimension's 'importedFeatures' field). Placed features are ores, trees, plants, springs, geodes and every other decoration entry a biome declares. This is OFF by default: with 'enabled' false Iris generates exactly the terrain it always has and no native feature runs. With it true, every placed feature the biome's vanilla derivative declares runs over Iris terrain, in vanilla step order, on the same worldgen thread vanilla uses. Carvers are never imported - Iris has no NoiseGeneratorSettings, so there is nothing for a carver to carve against. Family matching on 'disabled' uses namespace, slash, or underscore boundaries, so 'minecraft:ore' covers every vanilla ore feature without matching unrelated names.")
@Data
public class IrisImportedFeatureControl {
@Desc("Master switch. False (the default) means no native placed feature generates and chunk output is identical to a pack without this block at all. True runs the vanilla decoration feature pass over Iris terrain.")
private boolean enabled = false;
@ArrayType(type = String.class, min = 1)
@Desc("Placed feature keys to deny explicitly, e.g. 'minecraft:ore_diamond'. A namespace:path prefix also matches, so 'minecraft:ore' denies every vanilla ore and 'minecraft:trees' denies every tree placement. Every key not matched here generates while 'enabled' is true.")
private KList<String> disabled = new KList<>();
@ArrayType(type = IrisDecorationStep.class, min = 1)
@Desc("Restrict feature generation to these decoration steps only. Empty (the default) means every step is eligible. Use this to import ores without importing vegetation: set it to UNDERGROUND_ORES.")
private KList<IrisDecorationStep> steps = new KList<>();
@ArrayType(type = IrisDecorationStep.class, min = 1)
@Desc("Decoration steps to deny. Applied after 'steps', so a step listed here never generates even if 'steps' allows it. VEGETAL_DECORATION is the usual entry for packs that grow their own trees.")
private KList<IrisDecorationStep> disabledSteps = new KList<>();
/**
* True when this dimension wants the native feature pass at all. Every other accessor on this class is
* meaningless while this is false, and the platform must not build any feature machinery.
*/
public boolean shouldGenerateFeatures() {
return enabled;
}
public boolean shouldGenerateStep(IrisDecorationStep step) {
if (!enabled) {
return false;
}
KList<IrisDecorationStep> allowed = Objects.requireNonNull(
steps, "importedFeatures.steps must not be null");
KList<IrisDecorationStep> denied = Objects.requireNonNull(
disabledSteps, "importedFeatures.disabledSteps must not be null");
if (step == null) {
// A step this Iris build does not know about (newer Minecraft): allow it unless the pack
// narrowed generation to an explicit step list.
return allowed.isEmpty();
}
if (!allowed.isEmpty() && !allowed.contains(step)) {
return false;
}
return !denied.contains(step);
}
public boolean shouldGenerate(String placedFeatureKey) {
return resolve(placedFeatureKey, null) == NativeFeatureGenerationStatus.GENERATE_NATIVE;
}
/**
* Resolves one placed feature against this control. A null step skips the step gate, which is what the
* key-only query does.
*/
public NativeFeatureGenerationStatus resolve(String placedFeatureKey, IrisDecorationStep step) {
if (!enabled) {
return NativeFeatureGenerationStatus.FEATURES_DISABLED;
}
if (placedFeatureKey == null || placedFeatureKey.isBlank()) {
return NativeFeatureGenerationStatus.INVALID_REGISTRY_KEY;
}
if (step != null && !shouldGenerateStep(step)) {
return NativeFeatureGenerationStatus.STEP_DISABLED;
}
KList<String> deniedKeys = Objects.requireNonNull(
disabled, "importedFeatures.disabled must not be null");
for (String entry : deniedKeys) {
if (IrisImportedStructureControl.matchesKey(entry, placedFeatureKey)) {
return NativeFeatureGenerationStatus.DISABLED_BY_PACK;
}
}
return NativeFeatureGenerationStatus.GENERATE_NATIVE;
}
}
@@ -24,8 +24,6 @@ import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.RegistryListResource;
import art.arcane.iris.engine.object.annotations.Required;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.iris.util.common.plugin.VolmitSender;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -60,9 +58,4 @@ public class IrisJigsawPiece extends IrisRegistrant {
public String getTypeName() {
return "Jigsaw Piece";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -23,8 +23,6 @@ import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.RegistryListResource;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.iris.util.common.plugin.VolmitSender;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -55,9 +53,4 @@ public class IrisJigsawPool extends IrisRegistrant {
public String getTypeName() {
return "Jigsaw Pool";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -126,6 +126,21 @@ public class IrisLoot {
return BukkitBlockResolution.getMaterial(type);
}
/**
* The authored item key, exactly as written in the pack. Platform-neutral: {@link #getType()} resolves it against
* Bukkit, mod loaders resolve it against their own item registry.
*/
public String getTypeKey() {
return type;
}
/**
* The authored dye colour name, or null when unset. Platform-neutral counterpart to {@link #getDyeColor()}.
*/
public String getDyeColorKey() {
return dyeColor;
}
public DyeColor getDyeColor() {
if (dyeColor == null) {
return null;
@@ -26,9 +26,7 @@ import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber;
import art.arcane.iris.engine.object.annotations.Required;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.common.plugin.VolmitSender;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -114,9 +112,4 @@ public class IrisLootTable extends IrisRegistrant {
public String getTypeName() {
return "Loot";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -23,9 +23,7 @@ import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.RegistryListResource;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.common.plugin.VolmitSender;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -66,9 +64,4 @@ public class IrisMarker extends IrisRegistrant {
public String getTypeName() {
return "Marker";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -26,8 +26,6 @@ import art.arcane.iris.engine.object.annotations.MinNumber;
import art.arcane.iris.engine.object.annotations.RegistryListResource;
import art.arcane.iris.engine.object.annotations.Required;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.iris.util.common.plugin.VolmitSender;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -115,9 +113,4 @@ public class IrisMod extends IrisRegistrant {
public String getTypeName() {
return "Mod";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -32,7 +32,6 @@ import art.arcane.iris.util.common.math.IrisVector;
import art.arcane.iris.util.common.math.Vector3i;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.util.math.BlockPosition;
import art.arcane.volmlib.util.math.RNG;
import lombok.EqualsAndHashCode;
@@ -347,8 +346,4 @@ public class IrisObject extends IrisRegistrant {
public String getTypeName() {
return "Object";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -38,6 +38,8 @@ import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference;
@@ -45,10 +47,45 @@ import java.util.concurrent.atomic.AtomicReference;
* Binary (.iob) persistence for {@link IrisObject}. The field layout written here is pinned by the on-disk
* format - do not reorder reads or writes.
*/
final class IrisObjectIO {
public final class IrisObjectIO {
private static final String V2_HEADER = "Iris V2 IOB;";
private static final int MAX_PALETTE_ENTRIES = 32_767;
private IrisObjectIO() {
}
/**
* Reads only the V2 palette block-state keys out of an {@code .iob} header. Read-only pack-tooling hook: no
* IrisObject is built and no block state is resolved, so it runs without a bound platform.
* <p>
* Returns an empty list for a legacy (V1) object, an unreadable file, or a truncated header - a scan must never
* fail pack validation.
*/
public static List<String> readPaletteKeys(File file) {
if (file == null || !file.isFile()) {
return List.of();
}
try (DataInputStream din = new DataInputStream(new BufferedInputStream(new FileInputStream(file)))) {
din.readInt();
din.readInt();
din.readInt();
if (!V2_HEADER.equals(din.readUTF())) {
return List.of();
}
int count = din.readShort();
if (count <= 0 || count > MAX_PALETTE_ENTRIES) {
return List.of();
}
List<String> palette = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
palette.add(din.readUTF());
}
return palette;
} catch (Throwable e) {
return List.of();
}
}
static IrisBlockVector sampleSize(File file) throws IOException {
try (DataInputStream din = new DataInputStream(new FileInputStream(file))) {
return new IrisBlockVector(din.readInt(), din.readInt(), din.readInt());
@@ -340,7 +340,8 @@ public class IrisObjectPlacement {
private static IrisVanillaLootTable getVanillaTable(String name) {
return Optional.ofNullable(NamespacedKey.fromString(name))
.map(Bukkit::getLootTable)
.map(IrisVanillaLootTable::new)
// Hand over the key, not the LootTable: IrisVanillaLootTable holds no Bukkit fields.
.map(table -> new IrisVanillaLootTable(String.valueOf(table.getKey())))
.orElse(null);
}
@@ -36,10 +36,8 @@ import art.arcane.volmlib.util.collection.KSet;
import art.arcane.iris.util.common.data.DataProvider;
import art.arcane.volmlib.util.data.VanillaBiomeColors;
import art.arcane.volmlib.util.inventorygui.RandomColor;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.project.noise.CNG;
import art.arcane.iris.util.common.plugin.VolmitSender;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
@@ -489,9 +487,4 @@ public class IrisRegion extends IrisRegistrant implements IRare {
public String getTypeName() {
return "Region";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -24,8 +24,6 @@ import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.spi.PlatformWorld;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.iris.util.common.plugin.VolmitSender;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -134,9 +132,4 @@ public class IrisSpawner extends IrisRegistrant {
public String getTypeName() {
return "Spawner";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -26,8 +26,6 @@ import art.arcane.iris.engine.object.annotations.MinNumber;
import art.arcane.iris.engine.object.annotations.RegistryListResource;
import art.arcane.iris.engine.object.annotations.Required;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.iris.util.common.plugin.VolmitSender;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -95,9 +93,4 @@ public class IrisStructure extends IrisRegistrant {
public String getTypeName() {
return "Structure";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -0,0 +1,185 @@
/*
* Iris is a World Generator for Minecraft 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 java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Direct vanilla biome tag membership, generated from the MC 26.2 built-in datapack
* (data/minecraft/tags/worldgen/biome). Used to give Iris custom biomes the tag membership of the vanilla
* biome they derive from, so tag selectors written by vanilla and by mods (#minecraft:is_overworld and
* friends) resolve against Iris terrain.
*
* <p>Only DIRECT membership is listed. Vanilla's derived tags reference other tags rather than repeating
* biomes (#minecraft:is_ocean includes #minecraft:is_deep_ocean, water_on_map_outlines includes
* #minecraft:is_river, and so on), so adding a custom biome to the direct tags carries it into the derived
* tags for free.
*
* <p>The has_structure/* tags are deliberately absent. Iris resolves native structure placement through the
* biome's structure derivative, never through the custom biome, so injecting custom biomes there would place
* a structure twice.
*
* <p>{@code stronghold_biased_to} is absent for the same reason, even though it is not a has_structure tag.
* It is a structure-placement input: {@code worldgen/structure_set/strongholds.json} reads it as
* {@code preferred_biomes} when it rings strongholds around spawn. Inheriting it would enter every Iris biome
* derived from a tagged vanilla biome into that ring - which is the entire overworld surface of a typical pack -
* and let vanilla place strongholds where the pack's own structure configuration did not ask for them.
*
* <p>An unknown key (a mod biome, a datapack biome, or a vanilla biome added after 26.2) contributes nothing;
* the pack author's explicit tags still apply.
*/
public final class IrisVanillaBiomeTags {
private static final String TAG_NAMESPACE = "minecraft:";
private static final Map<String, List<String>> DIRECT_TAGS = new HashMap<>(96);
private IrisVanillaBiomeTags() {
}
/**
* Tags the given vanilla biome key belongs to directly. Never null; empty for unknown keys. Keys are
* returned fully namespaced and lowercase.
*/
public static List<String> tagsFor(String biomeKey) {
if (biomeKey == null || biomeKey.isBlank()) {
return List.of();
}
String normalized = biomeKey.trim().toLowerCase(Locale.ROOT);
if (normalized.indexOf(':') < 0) {
normalized = TAG_NAMESPACE + normalized;
}
List<String> tags = DIRECT_TAGS.get(normalized);
return tags == null ? List.of() : tags;
}
static int knownBiomeCount() {
return DIRECT_TAGS.size();
}
private static void put(String biomeKey, String... tagPaths) {
String[] namespaced = new String[tagPaths.length];
for (int i = 0; i < tagPaths.length; i++) {
namespaced[i] = TAG_NAMESPACE + tagPaths[i];
}
DIRECT_TAGS.put(biomeKey, List.of(namespaced));
}
static {
put("minecraft:badlands", "is_badlands", "is_overworld");
put("minecraft:bamboo_jungle", "is_jungle", "is_overworld");
put("minecraft:basalt_deltas", "is_nether");
put("minecraft:beach", "is_beach", "is_overworld");
put("minecraft:birch_forest", "is_forest", "is_overworld");
put("minecraft:cherry_grove", "is_mountain", "is_overworld");
put("minecraft:cold_ocean", "is_ocean", "is_overworld", "spawns_cold_variant_farm_animals");
put("minecraft:crimson_forest", "is_nether");
put("minecraft:dark_forest", "is_forest", "is_overworld");
put("minecraft:deep_cold_ocean", "is_deep_ocean", "is_overworld", "spawns_cold_variant_farm_animals");
put("minecraft:deep_dark",
"is_overworld", "mineshaft_blocking", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs");
put("minecraft:deep_frozen_ocean",
"is_deep_ocean", "is_overworld", "polar_bears_spawn_on_alternate_blocks", "spawns_cold_variant_farm_animals",
"spawns_cold_variant_frogs");
put("minecraft:deep_lukewarm_ocean", "is_deep_ocean", "is_overworld", "spawns_warm_variant_farm_animals");
put("minecraft:deep_ocean", "is_deep_ocean", "is_overworld");
put("minecraft:desert",
"is_overworld", "spawns_gold_rabbits", "spawns_warm_variant_farm_animals", "spawns_warm_variant_frogs");
put("minecraft:dripstone_caves", "is_overworld");
put("minecraft:end_barrens", "is_end");
put("minecraft:end_highlands", "is_end");
put("minecraft:end_midlands", "is_end");
put("minecraft:eroded_badlands", "is_badlands", "is_overworld");
put("minecraft:flower_forest", "is_forest", "is_overworld");
put("minecraft:forest", "is_forest", "is_overworld");
put("minecraft:frozen_ocean",
"is_ocean", "is_overworld", "polar_bears_spawn_on_alternate_blocks", "spawns_cold_variant_farm_animals",
"spawns_cold_variant_frogs", "spawns_snow_foxes", "spawns_white_rabbits");
put("minecraft:frozen_peaks",
"is_mountain", "is_overworld", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs",
"spawns_snow_foxes", "spawns_white_rabbits");
put("minecraft:frozen_river",
"is_overworld", "is_river", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs",
"spawns_snow_foxes", "spawns_white_rabbits");
put("minecraft:grove",
"is_forest", "is_overworld", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs",
"spawns_snow_foxes", "spawns_white_rabbits");
put("minecraft:ice_spikes",
"is_overworld", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs", "spawns_snow_foxes",
"spawns_white_rabbits");
put("minecraft:jagged_peaks",
"is_mountain", "is_overworld", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs",
"spawns_snow_foxes", "spawns_white_rabbits");
put("minecraft:jungle", "is_jungle", "is_overworld");
put("minecraft:lukewarm_ocean", "is_ocean", "is_overworld", "spawns_warm_variant_farm_animals");
put("minecraft:lush_caves", "allows_tropical_fish_spawns_at_any_height", "is_overworld");
put("minecraft:mangrove_swamp",
"allows_surface_slime_spawns", "is_overworld", "spawns_warm_variant_farm_animals",
"spawns_warm_variant_frogs", "water_on_map_outlines");
put("minecraft:meadow", "is_mountain", "is_overworld");
put("minecraft:mushroom_fields", "is_overworld", "without_zombie_sieges");
put("minecraft:nether_wastes", "is_nether");
put("minecraft:ocean", "is_ocean", "is_overworld");
put("minecraft:old_growth_birch_forest", "is_forest", "is_overworld");
put("minecraft:old_growth_pine_taiga",
"is_overworld", "is_taiga", "spawns_cold_variant_farm_animals");
put("minecraft:old_growth_spruce_taiga",
"is_overworld", "is_taiga", "spawns_cold_variant_farm_animals");
put("minecraft:pale_garden", "is_forest", "is_overworld");
put("minecraft:plains", "is_overworld");
put("minecraft:river", "is_overworld", "is_river");
put("minecraft:savanna", "is_overworld", "is_savanna");
put("minecraft:savanna_plateau", "is_overworld", "is_savanna");
put("minecraft:small_end_islands", "is_end");
put("minecraft:snowy_beach",
"is_beach", "is_overworld", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs",
"spawns_snow_foxes", "spawns_white_rabbits");
put("minecraft:snowy_plains",
"is_overworld", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs", "spawns_snow_foxes",
"spawns_white_rabbits");
put("minecraft:snowy_slopes",
"is_mountain", "is_overworld", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs",
"spawns_snow_foxes", "spawns_white_rabbits");
put("minecraft:snowy_taiga",
"is_overworld", "is_taiga", "spawns_cold_variant_farm_animals", "spawns_cold_variant_frogs",
"spawns_snow_foxes", "spawns_white_rabbits");
put("minecraft:soul_sand_valley", "is_nether");
put("minecraft:sparse_jungle", "is_jungle", "is_overworld");
put("minecraft:stony_peaks", "is_mountain", "is_overworld", "spawns_cold_variant_farm_animals");
put("minecraft:stony_shore", "is_overworld");
put("minecraft:sulfur_caves", "is_overworld");
put("minecraft:sunflower_plains", "is_overworld");
put("minecraft:swamp", "allows_surface_slime_spawns", "is_overworld", "water_on_map_outlines");
put("minecraft:taiga", "is_overworld", "is_taiga", "spawns_cold_variant_farm_animals");
put("minecraft:the_end", "is_end");
put("minecraft:the_void", "without_wandering_trader_spawns");
put("minecraft:warm_ocean",
"is_ocean", "is_overworld", "produces_corals_from_bonemeal", "spawns_coral_variant_zombie_nautilus",
"spawns_warm_variant_farm_animals", "spawns_warm_variant_frogs");
put("minecraft:warped_forest", "is_nether");
put("minecraft:windswept_forest",
"is_hill", "is_overworld", "spawns_cold_variant_farm_animals");
put("minecraft:windswept_gravelly_hills",
"is_hill", "is_overworld", "spawns_cold_variant_farm_animals");
put("minecraft:windswept_hills", "is_hill", "is_overworld", "spawns_cold_variant_farm_animals");
put("minecraft:windswept_savanna", "is_overworld", "is_savanna");
put("minecraft:wooded_badlands", "is_badlands", "is_overworld");
}
}
@@ -1,12 +1,16 @@
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.framework.LootResolver;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.inventory.ItemStack;
import org.bukkit.loot.LootContext;
@@ -17,11 +21,29 @@ import java.io.File;
@Data
@EqualsAndHashCode(callSuper = false)
public class IrisVanillaLootTable extends IrisLootTable {
private final LootTable lootTable;
/**
* The vanilla loot table key ("minecraft:chests/simple_dungeon"), not the LootTable itself. A
* raw org.bukkit.loot.LootTable field - and the constructor lombok would generate for it - makes
* every reflective walk over the IrisLootTable hierarchy (Gson, schema generation, the purity
* gate) resolve a Bukkit class. The table is resolved lazily at pull time, which only ever
* happens on Bukkit. The key text is identical to what {@code LootTable.getKey().toString()}
* produced before, so {@code LootResolver.tableIdentity} (which falls back to
* {@link #getName()}) yields the same loot seed.
*/
private final String lootTableKey;
/**
* The resolved table, looked up once. Every chest pull resolved it again - a NamespacedKey parse plus a registry
* lookup per container, and a dungeon room is a lot of containers.
* <p>
* Transient for the same reason the field above is a String: it keeps the Bukkit type argument out of the Gson
* field walk and out of the pack purity gate, both of which resolve generic field types for the fields they visit.
*/
private final transient AtomicCache<LootTable> resolvedTable = new AtomicCache<>();
@Override
public String getName() {
return "Vanilla " + lootTable.getKey();
return "Vanilla " + lootTableKey;
}
@Override
@@ -51,8 +73,20 @@ public class IrisVanillaLootTable extends IrisLootTable {
@Override
public KList<ItemStack> getLoot(boolean debug, long lootSeed, InventorySlotType slot, World world, int x, int y, int z) {
LootTable table = resolveTable();
if (table == null) {
IrisLogging.warn("Unknown vanilla loot table " + lootTableKey);
return new KList<>();
}
RNG rng = LootResolver.tableRng(lootSeed, this, x, y, z);
return new KList<>(lootTable.populateLoot(rng, new LootContext.Builder(new Location(world, x, y, z)).build()));
return new KList<>(table.populateLoot(rng, new LootContext.Builder(new Location(world, x, y, z)).build()));
}
private LootTable resolveTable() {
return resolvedTable.aquire(() -> {
NamespacedKey key = NamespacedKey.fromString(lootTableKey);
return key == null ? null : Bukkit.getLootTable(key);
});
}
@Override
@@ -1,71 +0,0 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.DependsOn;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.Required;
import art.arcane.iris.engine.object.annotations.Snippet;
import art.arcane.volmlib.util.collection.KList;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
@Snippet("villager-override")
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
@Desc("Override cartographer map trades with others or disable the trade altogether")
@Data
@EqualsAndHashCode(callSuper = false)
public class IrisVillagerOverride {
@Desc("""
Disable the trade altogether.
If a cartographer villager gets a new explorer map trade:
If this is enabled -> the trade is removed
If this is disabled -> the trade is replaced with the "override" setting below
Default is true, so if you omit this, trades will be removed.""")
private boolean disableTrade = true;
@DependsOn("disableTrade")
@Required
@Desc("""
The items to override the cartographer trade with.
By default, this is:
3 emeralds + 3 glass blocks -> 1 spyglass.
Can trade 3 to 5 times""")
@ArrayType(min = 1, type = IrisVillagerTrade.class)
private KList<IrisVillagerTrade> items = new KList<>(new IrisVillagerTrade()
.setIngredient1(new ItemStack(Material.EMERALD, 3))
.setIngredient2(new ItemStack(Material.GLASS, 3))
.setResult(new ItemStack(Material.SPYGLASS))
.setMinTrades(3)
.setMaxTrades(5));
public KList<IrisVillagerTrade> getValidItems() {
KList<IrisVillagerTrade> valid = new KList<>();
getItems().stream().filter(IrisVillagerTrade::isValidItems).forEach(valid::add);
return valid.size() == 0 ? null : valid;
}
}
@@ -1,152 +0,0 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2022 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.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber;
import art.arcane.iris.engine.object.annotations.RegistryListItemType;
import art.arcane.iris.engine.object.annotations.Required;
import art.arcane.iris.engine.object.annotations.Snippet;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.MerchantRecipe;
import java.util.List;
@Snippet("villager-trade")
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
@Desc("Represents a villager trade.")
@Data
@EqualsAndHashCode(callSuper = false)
public class IrisVillagerTrade {
@Required
@RegistryListItemType
@Desc("The first, required, ingredient for the trade.\nNote: this MUST be an item, and may not be a non-obtainable block!")
private ItemStack ingredient1;
@RegistryListItemType
@Desc("The second, optional, ingredient for the trade.\nNote: this MUST be an item, and may not be a non-obtainable block!")
private ItemStack ingredient2 = null;
@Required
@RegistryListItemType
@Desc("The result of the trade.\nNote: this MUST be an item, and may not be a non-obtainable block!")
private ItemStack result;
@Desc("The min amount of times this trade can be done. Default 3")
@MinNumber(1)
@MaxNumber(64)
private int minTrades = 3;
@Desc("The max amount of times this trade can be done. Default 5")
@MinNumber(1)
@MaxNumber(64)
private int maxTrades = 5;
/**
* @return true if:<br>
* ingredient 1 & result are non-null,<br>
* mintrades > 0, maxtrades > 0, maxtrades > mintrades, and<br>
* ingredient 1, (if defined ingredient 2) and the result are valid items
*/
public boolean isValidItems() {
KList<String> warnings = new KList<>();
if (ingredient1 == null) {
warnings.add("Ingredient 1 is null");
}
if (result == null) {
warnings.add("Result is null");
}
if (minTrades <= 0) {
warnings.add("Negative minimal trades");
}
if (maxTrades <= 0) {
warnings.add("Negative maximal trades");
}
if (minTrades > maxTrades) {
warnings.add("More minimal than maximal trades");
}
if (ingredient1 != null && !ingredient1.getType().isItem()) {
warnings.add("Ingredient 1 is not an item");
}
if (ingredient2 != null && !ingredient2.getType().isItem()) {
warnings.add("Ingredient 2 is not an item");
}
if (result != null && !result.getType().isItem()) {
warnings.add("Result is not an item");
}
if (warnings.isEmpty()) {
return true;
} else {
IrisLogging.warn("Faulty item in cartographer item overrides: " + this);
warnings.forEach(w -> IrisLogging.warn(" " + w));
return false;
}
}
/**
* Get the ingredients
*
* @return The list of 1 or 2 ingredients (depending on if ing2 is null)
*/
public List<ItemStack> getIngredients() {
if (!isValidItems()) {
return null;
}
return ingredient2 == null ? new KList<>(ingredient1) : new KList<>(ingredient1, ingredient2);
}
/**
* @return the amount of trades (RNG.r.i(min, max))
*/
public int getAmount() {
return RNG.r.i(minTrades, maxTrades);
}
/**
* @return the trade as a merchant recipe
*/
public MerchantRecipe convert() {
MerchantRecipe recipe = new MerchantRecipe(getResult(), getAmount());
recipe.setIngredients(getIngredients());
return recipe;
}
}
@@ -42,6 +42,14 @@ import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
/**
* Bukkit-only pre-key tile format. Unreachable from the modded loaders: every entry point is
* either Bukkit-typed ({@link #fromBukkit(BlockState)}, reached only from
* {@link TileData#getTileState(Block, boolean)} and the Bukkit structure importer) or sits behind
* the {@code BUKKIT_PRESENT} short-circuit in {@link TileData#read(DataInputStream)}, which hands
* off to the bound platform reader before this class is ever referenced. The nested handler types
* therefore keep their raw Bukkit fields.
*/
@ToString
@EqualsAndHashCode(callSuper = false)
public class LegacyTileData extends TileData {
@@ -87,7 +95,12 @@ public class LegacyTileData extends TileData {
}
@Override
public @NonNull Material getMaterial() {
public @NonNull String getMaterialKey() {
return TileData.materialKey(handler.getMaterial());
}
@Override
public Material resolveMaterial() {
return handler.getMaterial();
}
@@ -0,0 +1,28 @@
/*
* Iris is a World Generator for Minecraft 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;
public enum NativeFeatureGenerationStatus {
GENERATE_NATIVE,
FEATURES_DISABLED,
STEP_DISABLED,
DISABLED_BY_PACK,
CYCLE_DEGRADED,
INVALID_REGISTRY_KEY
}
@@ -41,7 +41,9 @@ import org.bukkit.block.data.BlockData;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
@SuppressWarnings("ALL")
@Getter
@@ -50,6 +52,18 @@ import java.util.Objects;
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TileData implements Cloneable {
private static final Gson gson = new GsonBuilder().disableHtmlEscaping().setStrictness(Strictness.LENIENT).setObjectToNumberStrategy(com.google.gson.ToNumberPolicy.LONG_OR_DOUBLE).create();
/**
* Memoized {@link #resolveMaterial()}. Pasting one tile resolves its material at least twice
* ({@link #isApplicable(BlockData)} then {@link #toBukkit(Block)}), and matchMaterial normalizes the string and
* then hits the Bukkit registry - an object with thousands of tiles paid for that thousands of times.
* <p>
* Static and keyed by the stored key rather than an instance field for two reasons: distinct tiles overwhelmingly
* repeat a handful of keys, so the cache is far more effective shared; and an instance field of a Bukkit type -
* transient or not - fails the pack purity gate, because {@code Class#getDeclaredFields()} resolves every declared
* field type eagerly, so one such field makes this class unloadable on Fabric/Forge/NeoForge. Static fields are
* never part of that walk. Bounded by the number of distinct material keys a pack can name.
*/
private static final Map<String, Material> RESOLVED_MATERIALS = new ConcurrentHashMap<>();
private static final boolean BUKKIT_PRESENT = detectBukkit();
private static volatile TileReader PLATFORM_READER = null;
private static volatile TileFactory PLATFORM_FACTORY = null;
@@ -91,11 +105,29 @@ public class TileData implements Cloneable {
}
}
/**
* The block key this tile belongs to, stored as text so the field type never drags
* org.bukkit.Material onto a Gson field walk or into generated equals/hashCode/toString.
* <p>
* Gson data-compat: Material serialized as its enum name, so the same JSON member name with a
* String type parses every existing pack byte-for-byte. Persisted values are either a legacy
* uppercase enum name ("CHEST") or a namespaced key ("minecraft:chest"); both are accepted at
* the Bukkit resolution edge in {@link #resolveMaterial()}.
*/
@Getter(AccessLevel.NONE)
@NonNull
private Material material;
private String material;
@NonNull
private KMap<String, Object> properties;
/**
* The platform-neutral block key. Safe on every platform - use this instead of resolving a
* Material anywhere outside the Bukkit adapter.
*/
public String getMaterialKey() {
return material;
}
public static boolean setTileState(Block block, TileData data) {
if (block.getState() instanceof TileState && data.isApplicable(block.getBlockData()))
return data.toBukkitTry(block);
@@ -122,7 +154,7 @@ public class TileData implements Cloneable {
if (!(handle instanceof BlockData blockData)) {
return null;
}
return new TileData(blockData.getMaterial(), properties);
return new TileData(materialKey(blockData.getMaterial()), properties);
}
public static TileData read(DataInputStream in) throws IOException {
@@ -133,9 +165,12 @@ public class TileData implements Cloneable {
throw new IOException("Mark not supported");
in.mark(Integer.MAX_VALUE);
try {
return new TileData(
Material.matchMaterial(in.readUTF()),
gson.fromJson(in.readUTF(), KMap.class));
// Resolving the material is the modern/legacy stream discriminator: an unresolvable
// first UTF means these bytes are a LegacyTileData record, not a modern one.
Material resolved = Material.matchMaterial(in.readUTF());
if (resolved == null)
throw new IOException("Not a modern tile record");
return new TileData(materialKey(resolved), gson.fromJson(in.readUTF(), KMap.class));
} catch (Throwable e) {
in.reset();
return new LegacyTileData(in);
@@ -144,6 +179,42 @@ public class TileData implements Cloneable {
}
}
/**
* Bukkit resolution edge: canonicalizes a Material to the namespaced key form that
* {@link #toBinary(DataOutputStream)} has always written, falling back to the enum name.
*/
static String materialKey(Material material) {
if (material == null) {
return "";
}
NamespacedKey key = KeyedType.getKey(material);
return key == null ? material.name() : key.toString();
}
/**
* Bukkit resolution edge. Accepts both persisted forms: the legacy uppercase enum name
* ("CHEST") and the namespaced key ("minecraft:chest") - matchMaterial handles both.
*/
public Material resolveMaterial() {
if (material == null || material.isEmpty()) {
return null;
}
Material cached = RESOLVED_MATERIALS.get(material);
if (cached != null) {
return cached;
}
Material resolved = Material.matchMaterial(material);
if (resolved != null) {
RESOLVED_MATERIALS.put(material, resolved);
}
return resolved;
}
static TileFactory requirePlatformFactory(TileFactory factory) {
if (factory == null) {
throw new IllegalStateException("No platform tile-data factory is bound");
@@ -159,20 +230,26 @@ public class TileData implements Cloneable {
}
public boolean isApplicable(BlockData data) {
return material != null && data.getMaterial() == material;
Material resolved = resolveMaterial();
return resolved != null && data.getMaterial() == resolved;
}
public void toBukkit(Block block) {
if (material == null) throw new IllegalStateException("Material not set");
if (block.getType() != material)
throw new IllegalStateException("Material mismatch: " + block.getType() + " vs " + material);
Material resolved = resolveMaterial();
if (resolved == null) throw new IllegalStateException("Material not set: " + material);
if (block.getType() != resolved)
throw new IllegalStateException("Material mismatch: " + block.getType() + " vs " + resolved);
BukkitPlatform.deserializeTile(properties, block.getLocation());
}
public TileData fromBukkit(Block block) {
if (material != null && block.getType() != material)
throw new IllegalStateException("Material mismatch: " + block.getType() + " vs " + material);
if (material == null) material = block.getType();
if (material != null && !material.isEmpty()) {
Material resolved = resolveMaterial();
if (block.getType() != resolved)
throw new IllegalStateException("Material mismatch: " + block.getType() + " vs " + material);
} else {
material = materialKey(block.getType());
}
properties = BukkitPlatform.serializeTile(block.getLocation());
return this;
}
@@ -203,13 +280,8 @@ public class TileData implements Cloneable {
}
public void toBinary(DataOutputStream out) throws IOException {
if (material == null) {
out.writeUTF("");
} else {
NamespacedKey key = KeyedType.getKey(material);
String value = key == null ? material.name() : key.toString();
out.writeUTF(value);
}
// The field already holds the canonical key form that this stream has always carried.
out.writeUTF(material == null ? "" : material);
out.writeUTF(gson.toJson(properties));
}
@@ -223,8 +295,6 @@ public class TileData implements Cloneable {
@Override
public String toString() {
NamespacedKey key = KeyedType.getKey(material);
String value = key == null ? String.valueOf(material) : key.toString();
return value + gson.toJson(properties);
return String.valueOf(material) + gson.toJson(properties);
}
}
@@ -0,0 +1,37 @@
/*
* 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.annotations;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
/**
* Marks a String field (or a list of Strings) as a live biome-registry key, so schema completion offers every
* vanilla, datapack, and mod biome instead of nothing.
*/
@Retention(RUNTIME)
@Target({PARAMETER, TYPE, FIELD})
public @interface RegistryListBiome {
}
@@ -5,10 +5,8 @@ import art.arcane.iris.core.loader.IrisRegistrant;
import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.util.project.matter.IrisMatterContext;
import art.arcane.iris.util.project.matter.IrisMatterSupport;
import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.volmlib.util.matter.IrisMatter;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.iris.util.common.plugin.VolmitSender;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -58,9 +56,4 @@ public class IrisMatterObject extends IrisRegistrant {
public String getTypeName() {
return "Matter";
}
@Override
public void scanForErrors(JSONObject p, VolmitSender sender) {
}
}
@@ -136,9 +136,8 @@ public final class EngineBukkitOps {
Block block = c.getBlock(x & 15, worldY, z & 15);
if (!TileData.setTileState(block, v.getData())) {
NamespacedKey blockTypeKey = KeyedType.getKey(block.getType());
NamespacedKey tileTypeKey = KeyedType.getKey(v.getData().getMaterial());
String blockType = blockTypeKey == null ? block.getType().name() : blockTypeKey.toString();
String tileType = tileTypeKey == null ? v.getData().getMaterial().name() : tileTypeKey.toString();
String tileType = v.getData().getMaterialKey();
IrisLogging.warn("Failed to set tile entity data at [%d %d %d | %s] for tile %s!", block.getX(), block.getY(), block.getZ(), blockType, tileType);
}
});
@@ -206,6 +206,19 @@ public final class BukkitBlockResolution {
return BASE.getOrNull(bdxf, warn);
}
/**
* Strict lookup: null when nothing claims the key, never an air substitute. Unlike {@link #getOrNull(String)} this
* never reaches the {@link art.arcane.iris.engine.object.IrisCompat} legacy rewrite table, which is a Bukkit-only
* layer and must stay off the generation path.
*/
public static BlockData resolveOrNull(String bdxf) {
return BASE.resolveOrNull(bdxf, false);
}
public static BlockData resolveOrNull(String bdxf, boolean warn) {
return BASE.resolveOrNull(bdxf, warn);
}
public static BlockData getNoCompat(String bdxf) {
return BASE.getNoCompat(bdxf);
}
@@ -59,15 +59,18 @@ public final class BukkitRegistries implements PlatformRegistries {
return data == null ? null : BukkitBlockState.of(data);
}
// blockOrNull must stay null-honest to match the modded adapters, so it uses the strict lookup rather than
// BukkitBlockResolution.getOrNull, which substitutes air for an unregistered key and feeds the Bukkit-only
// IrisCompat rewrite table used by block().
@Override
public PlatformBlockState blockOrNull(String key) {
BlockData data = BukkitBlockResolution.getOrNull(key);
BlockData data = BukkitBlockResolution.resolveOrNull(key);
return data == null ? null : BukkitBlockState.of(data);
}
@Override
public PlatformBlockState blockOrNull(String key, boolean warn) {
BlockData data = BukkitBlockResolution.getOrNull(key, warn);
BlockData data = BukkitBlockResolution.resolveOrNull(key, warn);
return data == null ? null : BukkitBlockState.of(data);
}
@@ -166,6 +169,19 @@ public final class BukkitRegistries implements PlatformRegistries {
return new ArrayList<>(Arrays.asList(BukkitBlockResolution.getBlockTypes()));
}
@Override
public List<String> specialEntityKeys() {
ExternalDataSVC external = IrisServices.getOrNull(ExternalDataSVC.class);
if (external == null) {
return List.of();
}
List<String> keys = new ArrayList<>();
for (Identifier identifier : external.getAllIdentifiers(DataType.ENTITY)) {
keys.add(identifier.toString());
}
return keys;
}
@Override
public List<String> enchantmentKeys() {
List<String> keys = new ArrayList<>();
@@ -4,8 +4,27 @@ import org.bukkit.Particle;
import static art.arcane.iris.util.common.data.registry.RegistryUtil.find;
/**
* Bukkit particle constants. Statically imported by {@code IrisEntity} (a Gson-registered pack
* type), so this class is reachable from core on the modded loaders. The resolution below is
* therefore guarded against the absent Bukkit class: without the guard the class initializer dies
* with a NoClassDefFoundError and the class stays permanently erroneous for the rest of the JVM's
* life. On Bukkit a genuinely missing registry key still throws, exactly as before.
*/
public class Particles {
public static final Particle CRIT_MAGIC = find(Particle.class, "crit_magic", "crit");
public static final Particle REDSTONE = find(Particle.class, "redstone", "dust");
public static final Particle ITEM = find(Particle.class, "item_crack", "item");
public static final Particle CRIT_MAGIC = resolve("crit_magic", "crit");
public static final Particle REDSTONE = resolve("redstone", "dust");
public static final Particle ITEM = resolve("item_crack", "item");
private static Particle resolve(String... keys) {
try {
return find(Particle.class, keys);
} catch (NoClassDefFoundError e) {
// No org.bukkit.Particle on this platform. Every read of these constants is Bukkit-only, so null is
// correct here. Narrower than LinkageError on purpose: a VerifyError, an IncompatibleClassChangeError or
// an ExceptionInInitializerError from the registry itself is a real defect on a Bukkit server and must
// not be silently turned into a null constant.
return null;
}
}
}
@@ -86,9 +86,15 @@ public class Bindings {
}
// bstats.org plugin id; 0 disables submission until the id is assigned
private static final int BSTATS_PLUGIN_ID = 0;
public static void setupBstats(VolmitPlugin plugin) {
if (BSTATS_PLUGIN_ID <= 0) {
return;
}
J.s(() -> {
var metrics = new Metrics(plugin, 24220);
var metrics = new Metrics(plugin, BSTATS_PLUGIN_ID);
metrics.addCustomChart(new SingleLineChart("custom_dimensions", () -> Bukkit.getWorlds()
.stream()
.filter(IrisToolbelt::isIrisWorld)
@@ -22,16 +22,32 @@ import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.io.IO;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
/**
* Download cache helpers over the platform data folder.
*
* <p>Every request is bounded: URL.openStream had no connect or read timeout, so a hung mirror parked the
* calling thread (a command thread, or the boot pack prefetch) forever.
*/
public final class WebCache {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10L);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(120L);
private static final int BUFFER_SIZE = 8192;
private static volatile HttpClient client;
private WebCache() {
}
@@ -44,16 +60,7 @@ public final class WebCache {
File f = IrisPlatforms.get().dataFile("cache", h.substring(0, 2), h.substring(3, 5), h);
if (!f.exists()) {
try (BufferedInputStream in = new BufferedInputStream(URI.create(url).toURL().openStream()); FileOutputStream fileOutputStream = new FileOutputStream(f)) {
byte[] dataBuffer = new byte[1024];
int bytesRead;
while ((bytesRead = in.read(dataBuffer, 0, 1024)) != -1) {
fileOutputStream.write(dataBuffer, 0, bytesRead);
IrisLogging.debug("Aquiring " + name);
}
} catch (IOException e) {
IrisLogging.reportError(e);
}
download(name, url, f);
}
return f.exists() ? f : null;
@@ -63,35 +70,76 @@ public final class WebCache {
String h = IO.hash(name + "*" + url);
File f = IrisPlatforms.get().dataFile("cache", h.substring(0, 2), h.substring(3, 5), h);
try (BufferedInputStream in = new BufferedInputStream(URI.create(url).toURL().openStream()); FileOutputStream fileOutputStream = new FileOutputStream(f)) {
byte[] dataBuffer = new byte[1024];
int bytesRead;
while ((bytesRead = in.read(dataBuffer, 0, 1024)) != -1) {
fileOutputStream.write(dataBuffer, 0, bytesRead);
}
if (!download(name, url, f)) {
return "";
}
try {
return Files.readString(f.toPath(), StandardCharsets.UTF_8);
} catch (IOException e) {
IrisLogging.reportError(e);
return "";
}
return "";
}
public static File getNonCachedFile(String name, String url) {
String h = IO.hash(name + "*" + url);
File f = IrisPlatforms.get().dataFile("cache", h.substring(0, 2), h.substring(3, 5), h);
IrisLogging.debug("Download " + name + " -> " + url);
try (BufferedInputStream in = new BufferedInputStream(URI.create(url).toURL().openStream()); FileOutputStream fileOutputStream = new FileOutputStream(f)) {
byte[] dataBuffer = new byte[1024];
int bytesRead;
while ((bytesRead = in.read(dataBuffer, 0, 1024)) != -1) {
fileOutputStream.write(dataBuffer, 0, bytesRead);
}
fileOutputStream.flush();
} catch (IOException e) {
IrisLogging.reportError(e);
}
download(name, url, f);
return f;
}
private static boolean download(String name, String url, File target) {
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(REQUEST_TIMEOUT)
.GET()
.build();
try {
HttpResponse<InputStream> response = client()
.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() / 100 != 2) {
try (InputStream discard = response.body()) {
discard.readAllBytes();
}
IrisLogging.reportError(new IOException("HTTP " + response.statusCode()
+ " downloading " + name + " from " + url));
return false;
}
try (InputStream in = response.body();
OutputStream out = Files.newOutputStream(target.toPath(),
StandardOpenOption.CREATE, StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING)) {
byte[] buffer = new byte[BUFFER_SIZE];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
}
out.flush();
}
return true;
} catch (IOException e) {
IrisLogging.reportError(e);
return false;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
IrisLogging.reportError(e);
return false;
}
}
private static HttpClient client() {
HttpClient current = client;
if (current != null) {
return current;
}
synchronized (WebCache.class) {
if (client == null) {
client = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
}
return client;
}
}
}
@@ -48,7 +48,9 @@ public class KeyedType {
@Nullable
public static NamespacedKey getKey(Object value) {
if (value == null) {
// KEYED_PRESENT first: without it the instanceof below resolves org.bukkit.Keyed and
// NoClassDefFoundErrors on the modded loaders instead of degrading to null.
if (!KEYED_PRESENT || value == null) {
return null;
}
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Verbindung zum Iris-Server wird hergestellt...",
"iris.client.vision.not_connected": "nicht verbunden",
"iris.client.vision.not_iris_world": "Keine Iris-Welt",
"iris.client.vision.server_without_iris": "Dieser Server verwendet kein Iris",
"iris.client.vision.version_mismatch": "Iris Versionskonflikt zwischen Client und Server",
"iris.client.vision.no_dimension_data": "Keine Dimensionsdaten",
"iris.client.vision.header_detail": "{status} Zoom {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "Ziehen zum Verschieben, scrollen zum Zoomen, Esc zum Schließen",
"iris.client.vision.dimension_pack": "{dimension} Pack {pack}",
"iris.client.create.structures_required_title": "Iris benötigt Bauwerke generieren",
"iris.client.create.structures_required_body": "Iris platziert seine eigenen Bauwerke über den Bauwerk-Generierungsschritt und lädt keine Welt, die mit deaktiviertem Bauwerke generieren erstellt wurde. Aktiviere Bauwerke generieren wieder oder wähle einen anderen Welttyp.",
"iris.client.toast.studio_hotload": "Studio-Hotload",
"iris.client.toast.changed_files": {
"other": "{count} Dateien",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "Höhe: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "pausiert",
"iris.client.pregen.stale": "keine Updates seit {seconds}s",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Conectando al servidor Iris...",
"iris.client.vision.not_connected": "sin conexión",
"iris.client.vision.not_iris_world": "No es un mundo de Iris",
"iris.client.vision.server_without_iris": "Este servidor no ejecuta Iris",
"iris.client.vision.version_mismatch": "Conflicto de versión de Iris entre cliente y servidor",
"iris.client.vision.no_dimension_data": "sin datos de dimensión",
"iris.client.vision.header_detail": "{status} zoom {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "Arrastrar para mover Rueda para acercar Esc para cerrar",
"iris.client.vision.dimension_pack": "{dimension} pack {pack}",
"iris.client.create.structures_required_title": "Iris requiere Generar estructuras",
"iris.client.create.structures_required_body": "Iris coloca sus propias estructuras en el paso de generación de estructuras y no carga un mundo creado con Generar estructuras desactivado. Vuelve a activar Generar estructuras o elige otro tipo de mundo.",
"iris.client.toast.studio_hotload": "Recarga en caliente de Studio",
"iris.client.toast.changed_files": {
"other": "{count} archivos",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "Altura: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "PAUSADO",
"iris.client.pregen.stale": "sin actualizaciones desde {seconds}s",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Yhdistetään Iris palvelin...",
"iris.client.vision.not_connected": "ei yhdistetty",
"iris.client.vision.not_iris_world": "Ei Iris maailma",
"iris.client.vision.server_without_iris": "Tämä palvelin ei käytä Iris",
"iris.client.vision.version_mismatch": "Iris versioristiriita asiakkaan ja palvelimen välillä",
"iris.client.vision.no_dimension_data": "ei ulottuvuustietoja",
"iris.client.vision.header_detail": "{status} zoomaus {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "Vedä Pan vieritä zoomata Esc sulkea",
"iris.client.vision.dimension_pack": "{dimension} Pakkaus {pack}",
"iris.client.create.structures_required_title": "Iris vaatii Luo rakennelmat",
"iris.client.create.structures_required_body": "Iris sijoittaa omat rakennelmansa rakennelmien luontivaiheessa eikä lataa maailmaa, joka luotiin Luo rakennelmat pois kytkettynä. Kytke Luo rakennelmat takaisin päälle tai valitse toinen maailmatyyppi.",
"iris.client.toast.studio_hotload": "Studiohotload",
"iris.client.toast.changed_files": {
"other": "{count} tiedostot",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "Korkeus: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "keskeytetty",
"iris.client.pregen.stale": "ei päivityksiä {seconds}s ajan",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Connexion au serveur Iris...",
"iris.client.vision.not_connected": "non connecté",
"iris.client.vision.not_iris_world": "Ce n'est pas un monde Iris",
"iris.client.vision.server_without_iris": "Ce serveur n'utilise pas Iris",
"iris.client.vision.version_mismatch": "Version Iris incompatible entre le client et le serveur",
"iris.client.vision.no_dimension_data": "aucune donnée de dimension",
"iris.client.vision.header_detail": "{status} zoom {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "Glisser pour déplacer Molette pour zoomer Échap pour fermer",
"iris.client.vision.dimension_pack": "{dimension} pack {pack}",
"iris.client.create.structures_required_title": "Iris nécessite Générer des structures",
"iris.client.create.structures_required_body": "Iris place ses propres structures pendant l'étape de génération des structures et refuse de charger un monde créé avec Générer des structures désactivé. Réactive Générer des structures ou choisis un autre type de monde.",
"iris.client.toast.studio_hotload": "Rechargement à chaud de Studio",
"iris.client.toast.changed_files": {
"other": "{count} fichiers",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "Hauteur : {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "EN PAUSE",
"iris.client.pregen.stale": "aucune mise à jour depuis {seconds}s",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "להתחבר Iris שרת Server...",
"iris.client.vision.not_connected": "לא מחובר",
"iris.client.vision.not_iris_world": "לא Iris עולם העולם",
"iris.client.vision.server_without_iris": "שרת זה אינו מריץ Iris",
"iris.client.vision.version_mismatch": "אי התאמה בגרסת Iris בין הלקוח לשרת",
"iris.client.vision.no_dimension_data": "אין נתונים ממדיים",
"iris.client.vision.header_detail": "{status} גן החיות {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "גרור להזזה, גלול לשינוי מרחק התצוגה, Esc לסגירה",
"iris.client.vision.dimension_pack": "{dimension} חבילות {pack}",
"iris.client.create.structures_required_title": "Iris דורש יצירת מבנים",
"iris.client.create.structures_required_body": "Iris מציב את המבנים שלו בשלב יצירת המבנים ואינו טוען עולם שנוצר עם יצירת מבנים כבויה. הפעל שוב את יצירת מבנים או בחר סוג עולם אחר.",
"iris.client.toast.studio_hotload": "סטודיו טעינה חמה",
"iris.client.toast.changed_files": {
"other": "{count} קבצים",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "גובה: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "מושהה",
"iris.client.pregen.stale": "אין עדכונים במשך {seconds} שניות",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Connessione al server Iris...",
"iris.client.vision.not_connected": "non collegato",
"iris.client.vision.not_iris_world": "Non è un mondo Iris",
"iris.client.vision.server_without_iris": "Questo server non usa Iris",
"iris.client.vision.version_mismatch": "Versione di Iris non compatibile tra client e server",
"iris.client.vision.no_dimension_data": "nessun dato sulla dimensione",
"iris.client.vision.header_detail": "{status} zoom {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "Trascina per spostare Scorri per ingrandire Esc per chiudere",
"iris.client.vision.dimension_pack": "{dimension} Pack {pack}",
"iris.client.create.structures_required_title": "Iris richiede Genera strutture",
"iris.client.create.structures_required_body": "Iris posiziona le proprie strutture durante la fase di generazione delle strutture e non carica un mondo creato con Genera strutture disattivato. Riattiva Genera strutture oppure scegli un altro tipo di mondo.",
"iris.client.toast.studio_hotload": "Ricaricamento rapido di Studio",
"iris.client.toast.changed_files": {
"other": "{count} file",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "Altezza: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "in pausa",
"iris.client.pregen.stale": "nessun aggiornamento da {seconds}s",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Iris サーバーへ接続しています...",
"iris.client.vision.not_connected": "未接続",
"iris.client.vision.not_iris_world": "Iris ワールドではありません",
"iris.client.vision.server_without_iris": "このサーバーは Iris を使用していません",
"iris.client.vision.version_mismatch": "クライアントとサーバーの Iris バージョンが一致しません",
"iris.client.vision.no_dimension_data": "ディメンションデータなし",
"iris.client.vision.header_detail": "{status} ズーム {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "ドラッグで移動 スクロールでズーム Esc で閉じる",
"iris.client.vision.dimension_pack": "{dimension} パック {pack}",
"iris.client.create.structures_required_title": "Iris は「構造物を生成」が必要です",
"iris.client.create.structures_required_body": "Iris は構造物生成ステップで独自の構造物を配置するため、「構造物を生成」をオフにして作成したワールドは読み込めません。「構造物を生成」をオンに戻すか、別のワールドタイプを選んでください。",
"iris.client.toast.studio_hotload": "スタジオホットロード",
"iris.client.toast.changed_files": {
"other": "{count} ファイル",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "高さ: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "一時停止中",
"iris.client.pregen.stale": "{seconds} 秒間更新がありません",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Iris 서버에 연결 중...",
"iris.client.vision.not_connected": "연결되지 않음",
"iris.client.vision.not_iris_world": "Iris 월드가 아님",
"iris.client.vision.server_without_iris": "이 서버는 Iris를 사용하지 않습니다",
"iris.client.vision.version_mismatch": "클라이언트와 서버의 Iris 버전이 일치하지 않습니다",
"iris.client.vision.no_dimension_data": "차원 데이터 없음",
"iris.client.vision.header_detail": "{status} 확대 {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "드래그로 이동 스크롤로 확대 Esc로 닫기",
"iris.client.vision.dimension_pack": "{dimension} 팩 {pack}",
"iris.client.create.structures_required_title": "Iris에는 구조물 생성이 필요합니다",
"iris.client.create.structures_required_body": "Iris는 구조물 생성 단계에서 자체 구조물을 배치하므로 구조물 생성을 끈 상태로 만든 월드는 불러올 수 없습니다. 구조물 생성을 다시 켜거나 다른 월드 유형을 선택하세요.",
"iris.client.toast.studio_hotload": "스튜디오 핫로드",
"iris.client.toast.changed_files": {
"other": "파일 {count}개",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "고도: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "일시 중지",
"iris.client.pregen.stale": "{seconds}초 동안 업데이트 없음",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Jungiamasi prie Iris serveris...",
"iris.client.vision.not_connected": "neprijungta",
"iris.client.vision.not_iris_world": "Ne Iris pasaulis",
"iris.client.vision.server_without_iris": "Šis serveris nenaudoja Iris",
"iris.client.vision.version_mismatch": "Iris versijos neatitikimas tarp kliento ir serverio",
"iris.client.vision.no_dimension_data": "Nėra matmenų duomenų",
"iris.client.vision.header_detail": "{status} priartinimas {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "Vilkti į visos slinkti iki didinimo Esc uždaryti",
"iris.client.vision.dimension_pack": "{dimension} pakuotė {pack}",
"iris.client.create.structures_required_title": "Iris reikia Generuoti statinius",
"iris.client.create.structures_required_body": "Iris savo statinius sudeda statinių generavimo etape ir neįkelia pasaulio, sukurto išjungus Generuoti statinius. Vėl įjunk Generuoti statinius arba pasirink kitą pasaulio tipą.",
"iris.client.toast.studio_hotload": "Studija Hotload",
"iris.client.toast.changed_files": {
"other": "{count} failai",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "Aukštis: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "pristabdyta",
"iris.client.pregen.stale": "nėra atnaujinimų {seconds}s",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Verbinden met Iris server...",
"iris.client.vision.not_connected": "niet verbonden",
"iris.client.vision.not_iris_world": "Geen Iris wereld",
"iris.client.vision.server_without_iris": "Deze server gebruikt geen Iris",
"iris.client.vision.version_mismatch": "Iris versieconflict tussen client en server",
"iris.client.vision.no_dimension_data": "geen dimensiegegevens",
"iris.client.vision.header_detail": "{status} zoomen {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "Sleep naar pan scrollen om Esc te sluiten",
"iris.client.vision.dimension_pack": "{dimension} verpakking {pack}",
"iris.client.create.structures_required_title": "Iris vereist Structuren genereren",
"iris.client.create.structures_required_body": "Iris plaatst zijn eigen structuren in de structuurgeneratiestap en laadt geen wereld die is aangemaakt met Structuren genereren uit. Zet Structuren genereren weer aan of kies een ander wereldtype.",
"iris.client.toast.studio_hotload": "Studio HotloadName",
"iris.client.toast.changed_files": {
"other": "{count} bestanden",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "Hoogte: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "gepauzeerd",
"iris.client.pregen.stale": "geen updates sinds {seconds}s",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Łączenie Iris serwer...",
"iris.client.vision.not_connected": "niepodłączony",
"iris.client.vision.not_iris_world": "Nie Iris świat",
"iris.client.vision.server_without_iris": "Ten serwer nie używa Iris",
"iris.client.vision.version_mismatch": "Niezgodna wersja Iris między klientem a serwerem",
"iris.client.vision.no_dimension_data": "brak danych dotyczących wymiarów",
"iris.client.vision.header_detail": "{status} powiększenie {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "Przeciągnij do paska Przewiń, aby powiększyć Esc zamknąć",
"iris.client.vision.dimension_pack": "{dimension} opakowanie {pack}",
"iris.client.create.structures_required_title": "Iris wymaga opcji Generuj budowle",
"iris.client.create.structures_required_body": "Iris umieszcza własne budowle na etapie generowania budowli i nie wczyta świata utworzonego z wyłączoną opcją Generuj budowle. Włącz ponownie Generuj budowle albo wybierz inny typ świata.",
"iris.client.toast.studio_hotload": "Hotload Studio",
"iris.client.toast.changed_files": {
"other": "{count} pliki",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "Wysokość: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "wstrzymano",
"iris.client.pregen.stale": "brak aktualizacji od {seconds}s",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Conectando a Iris servidor...",
"iris.client.vision.not_connected": "não conectado",
"iris.client.vision.not_iris_world": "Não é um Iris mundo",
"iris.client.vision.server_without_iris": "Este servidor não usa Iris",
"iris.client.vision.version_mismatch": "Versão de Iris incompatível entre cliente e servidor",
"iris.client.vision.no_dimension_data": "sem dados de dimensão",
"iris.client.vision.header_detail": "{status} ampliação {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "Arraste para a panela Role para ampliar Esc para fechar",
"iris.client.vision.dimension_pack": "{dimension} pack {pack}",
"iris.client.create.structures_required_title": "Iris requer Gerar estruturas",
"iris.client.create.structures_required_body": "Iris coloca as suas próprias estruturas na etapa de geração de estruturas e não carrega um mundo criado com Gerar estruturas desativado. Volta a ativar Gerar estruturas ou escolhe outro tipo de mundo.",
"iris.client.toast.studio_hotload": "Espaço de carga quente",
"iris.client.toast.changed_files": {
"other": "{count} ficheiros",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "Altura: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "pausado",
"iris.client.pregen.stale": "sem atualizações há {seconds}s",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Подключение к Iris сервер...",
"iris.client.vision.not_connected": "не подключенный",
"iris.client.vision.not_iris_world": "Ни один Iris мир",
"iris.client.vision.server_without_iris": "На этом сервере не используется Iris",
"iris.client.vision.version_mismatch": "Несовпадение версий Iris у клиента и сервера",
"iris.client.vision.no_dimension_data": "Нет данных измерений",
"iris.client.vision.header_detail": "{status} увеличение {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "перетаскивать на свиток, чтобы увеличить бегство скачать",
"iris.client.vision.dimension_pack": "{dimension} пак {pack}",
"iris.client.create.structures_required_title": "Для Iris требуется генерация построек",
"iris.client.create.structures_required_body": "Iris размещает свои постройки на этапе генерации построек и не загружает мир, созданный с отключённой генерацией построек. Включи генерацию построек снова или выбери другой тип мира.",
"iris.client.toast.studio_hotload": "Студия Hotload",
"iris.client.toast.changed_files": {
"other": "{count} файлы",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "Высота: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "приостановлено",
"iris.client.pregen.stale": "нет обновлений {seconds} с",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Bağlanmak için Iris server sunucusu...",
"iris.client.vision.not_connected": "Bağlanmamaya bağlı değil",
"iris.client.vision.not_iris_world": "Bir şey değil Iris dünya dünyası",
"iris.client.vision.server_without_iris": "Bu sunucu Iris kullanmıyor",
"iris.client.vision.version_mismatch": "İstemci ve sunucu arasında Iris sürüm uyuşmazlığı",
"iris.client.vision.no_dimension_data": "Hiçbir Boyut Verileri",
"iris.client.vision.header_detail": "{status} yakınlaştırma {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "Kaydırmak için sürükleyin, yakınlaştırmak için kaydırın, kapatmak için Esc tuşuna basın",
"iris.client.vision.dimension_pack": "{dimension} paket paketi {pack}",
"iris.client.create.structures_required_title": "Iris için Yapılar oluştur gerekli",
"iris.client.create.structures_required_body": "Iris kendi yapılarını yapı oluşturma adımında yerleştirir ve Yapılar oluştur kapalıyken oluşturulan bir dünyayı yüklemez. Yapılar oluştur seçeneğini yeniden aç veya farklı bir dünya türü seç.",
"iris.client.toast.studio_hotload": "Stüdyo Hotload",
"iris.client.toast.changed_files": {
"other": "{count} dosyaları dosyalar",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "Yükseklik: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "duraklatıldı",
"iris.client.pregen.stale": "{seconds}s boyunca güncelleme yok",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "Đang kết nối tới Iris máy phục vụ...",
"iris.client.vision.not_connected": "chưa kết nối",
"iris.client.vision.not_iris_world": "Không phải Iris thế giới",
"iris.client.vision.server_without_iris": "Máy chủ này không dùng Iris",
"iris.client.vision.version_mismatch": "Phiên bản Iris không khớp giữa máy khách và máy chủ",
"iris.client.vision.no_dimension_data": "không có dữ liệu chiều không gian",
"iris.client.vision.header_detail": "{status} phóng đại {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "Kéo tới cuộn để thu nhỏ Esc",
"iris.client.vision.dimension_pack": "{dimension} gói {pack}",
"iris.client.create.structures_required_title": "Iris cần bật Tạo kiến trúc",
"iris.client.create.structures_required_body": "Iris đặt kiến trúc riêng của mình trong bước tạo kiến trúc và không tải thế giới được tạo khi Tạo kiến trúc đang tắt. Hãy bật lại Tạo kiến trúc hoặc chọn loại thế giới khác.",
"iris.client.toast.studio_hotload": "Nạp nóng phòng thu",
"iris.client.toast.changed_files": {
"other": "{count} Tập tin",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "Chiều cao: {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "đã tạm dừng",
"iris.client.pregen.stale": "không có cập nhật trong {seconds}s",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "正在连接到 Iris 服务器...",
"iris.client.vision.not_connected": "未连接",
"iris.client.vision.not_iris_world": "没有 Iris 世界",
"iris.client.vision.server_without_iris": "此服务器未运行 Iris",
"iris.client.vision.version_mismatch": "客户端与服务器的 Iris 版本不一致",
"iris.client.vision.no_dimension_data": "无维度数据",
"iris.client.vision.header_detail": "{status} 缩放 {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "拖动以平移,滚动以缩放,按 Esc 关闭",
"iris.client.vision.dimension_pack": "{dimension} 包 {pack}",
"iris.client.create.structures_required_title": "Iris 需要开启“生成建筑”",
"iris.client.create.structures_required_body": "Iris 在建筑生成阶段放置自己的建筑,因此无法加载在关闭“生成建筑”时创建的世界。请重新开启“生成建筑”,或选择其他世界类型。",
"iris.client.toast.studio_hotload": "工作室热负荷",
"iris.client.toast.changed_files": {
"other": "{count} 文件",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "高度 : {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "已暂停",
"iris.client.pregen.stale": "已有 {seconds} 秒没有更新",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -1306,10 +1306,14 @@
"iris.client.vision.connecting": "正在連線到 Iris 伺服器...",
"iris.client.vision.not_connected": "未連線",
"iris.client.vision.not_iris_world": "沒有 Iris 世界",
"iris.client.vision.server_without_iris": "此伺服器未執行 Iris",
"iris.client.vision.version_mismatch": "用戶端與伺服器的 Iris 版本不一致",
"iris.client.vision.no_dimension_data": "無維度資料",
"iris.client.vision.header_detail": "{status} 縮放 {zoom} x{x} z{z}",
"iris.client.vision.footer_hint": "拖曳以平移,捲動以縮放,按 Esc 關閉",
"iris.client.vision.dimension_pack": "{dimension} 包 {pack}",
"iris.client.create.structures_required_title": "Iris 需要開啟「產生建築」",
"iris.client.create.structures_required_body": "Iris 會在建築產生階段放置自己的建築,因此無法載入在關閉「產生建築」時建立的世界。請重新開啟「產生建築」,或選擇其他世界類型。",
"iris.client.toast.studio_hotload": "工作室熱負荷",
"iris.client.toast.changed_files": {
"other": "{count} 檔案",
@@ -1326,6 +1330,7 @@
"iris.client.what.height": "高度 : {height} ({x}, {z})",
"iris.client.pregen.stats": "{done} / {total} ({percent}%)",
"iris.client.pregen.paused": "已暫停",
"iris.client.pregen.stale": "已有 {seconds} 秒沒有更新",
"iris.client.pregen.rate": "{rate}/s",
"iris.client.pregen.rate_eta": "{rate}/s ETA {eta}",
"iris.client.duration.hours_minutes": "{hours}h {minutes}m",
@@ -0,0 +1,63 @@
package art.arcane.iris.core.gui;
import art.arcane.iris.core.IrisSettings;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.awt.GraphicsEnvironment;
import static org.junit.Assert.assertEquals;
public class GuiHostServerGuiLaunchTest {
private IrisSettings previousSettings;
private boolean previousSuppressed;
@Before
public void before() {
previousSettings = IrisSettings.settings;
previousSuppressed = GuiHost.isDesktopSuppressed();
IrisSettings.settings = new IrisSettings();
}
@After
public void after() {
GuiHost.suppressDesktop(previousSuppressed);
IrisSettings.settings = previousSettings;
}
@Test
public void serverGuiLaunchIsDisabledWhenNotRequested() {
GuiHost.suppressDesktop(false);
IrisSettings.settings.getGui().setUseServerLaunchedGuis(true);
assertEquals(GuiHost.ServerGuiLaunch.DISABLED, GuiHost.serverGuiLaunch(false));
}
@Test
public void serverGuiLaunchIsDisabledWhenSettingIsOff() {
GuiHost.suppressDesktop(false);
IrisSettings.settings.getGui().setUseServerLaunchedGuis(false);
assertEquals(GuiHost.ServerGuiLaunch.DISABLED, GuiHost.serverGuiLaunch(true));
}
@Test
public void serverGuiLaunchIsUnavailableWhenDesktopIsSuppressed() {
GuiHost.suppressDesktop(true);
IrisSettings.settings.getGui().setUseServerLaunchedGuis(true);
assertEquals(GuiHost.ServerGuiLaunch.UNAVAILABLE, GuiHost.serverGuiLaunch(true));
}
@Test
public void serverGuiLaunchOpensOnlyWithADisplayEnvironment() {
GuiHost.suppressDesktop(false);
IrisSettings.settings.getGui().setUseServerLaunchedGuis(true);
GuiHost.ServerGuiLaunch expected = GraphicsEnvironment.isHeadless()
? GuiHost.ServerGuiLaunch.UNAVAILABLE
: GuiHost.ServerGuiLaunch.OPEN;
assertEquals(expected, GuiHost.serverGuiLaunch(true));
}
}
@@ -7,6 +7,7 @@ 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.PlatformNumericRange;
import art.arcane.iris.spi.PlatformRegistries;
import org.junit.Test;
@@ -15,6 +16,7 @@ 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 ContentKeyValidatorTest {
@@ -22,7 +24,56 @@ public class ContentKeyValidatorTest {
return new FakeRegistries(
List.of("minecraft:stone", "minecraft:cobblestone", "minecraft:oak_log", "minecraft:grass_block"),
List.of("minecraft:diamond", "minecraft:wooden_pickaxe", "minecraft:stone_pickaxe"),
List.of("minecraft:zombie", "minecraft:creeper"));
List.of("minecraft:zombie", "minecraft:creeper"),
Map.of());
}
private static PlatformRegistries registriesWithProperties() {
return new FakeRegistries(
List.of("minecraft:stone", "minecraft:oak_log", "create:cogwheel"),
List.of(),
List.of(),
Map.of(
"minecraft:oak_log", List.of(
new PlatformBlockProperty("axis", "string", "y", List.of("x", "y", "z"), null),
new PlatformBlockProperty("waterlogged", "boolean", false, List.of(true, false), null)),
// The Bukkit shape for a numeric property: no enumerable values, bounds instead. Modded
// enumerates 0..15 into allowedValues, so both must be validated the same way.
"minecraft:water", List.of(
new PlatformBlockProperty("level", "integer", 0, List.of(),
new PlatformNumericRange(0, 15, false, false)),
new PlatformBlockProperty("custom", "string", "a", List.of(), null)),
"create:cogwheel", List.of()));
}
@Test
public void validateBlockStatePropertiesFlagsValueAboveDeclaredRange() {
List<String> messages = ContentKeyValidator.validateBlockStateProperties(registriesWithProperties(),
List.of("minecraft:water[level=99]"));
assertEquals(1, messages.size());
assertTrue(messages.get(0), messages.get(0).contains("does not accept '99'"));
assertTrue(messages.get(0), messages.get(0).contains("at least 0"));
assertTrue(messages.get(0), messages.get(0).contains("at most 15"));
}
@Test
public void validateBlockStatePropertiesFlagsNonNumericValueForNumericProperty() {
List<String> messages = ContentKeyValidator.validateBlockStateProperties(registriesWithProperties(),
List.of("minecraft:water[level=full]"));
assertEquals(1, messages.size());
assertTrue(messages.get(0), messages.get(0).contains("is numeric and does not accept 'full'"));
}
@Test
public void validateBlockStatePropertiesAcceptsValueInsideDeclaredRange() {
assertTrue(ContentKeyValidator.validateBlockStateProperties(registriesWithProperties(),
List.of("minecraft:water[level=0]", "minecraft:water[level=15]", "minecraft:water[level=7]")).isEmpty());
}
@Test
public void validateBlockStatePropertiesStaysSilentWithoutValuesOrRange() {
assertTrue(ContentKeyValidator.validateBlockStateProperties(registriesWithProperties(),
List.of("minecraft:water[custom=anything]")).isEmpty());
}
@Test
@@ -89,7 +140,74 @@ public class ContentKeyValidatorTest {
assertTrue(ContentKeyValidator.validate(null, List.of("minecraft:whatever"), List.of(), List.of()).isEmpty());
}
private record FakeRegistries(List<String> blocks, List<String> items, List<String> entities) implements PlatformRegistries {
@Test
public void validateBlockStatePropertiesFlagsUnknownPropertyWithSuggestion() {
List<String> messages = ContentKeyValidator.validateBlockStateProperties(registriesWithProperties(),
List.of("minecraft:oak_log[axi=y]"));
assertEquals(1, messages.size());
assertTrue(messages.get(0).contains("has no property 'axi'"));
assertTrue(messages.get(0).contains("did you mean 'axis'"));
}
@Test
public void validateBlockStatePropertiesFlagsDisallowedValue() {
List<String> messages = ContentKeyValidator.validateBlockStateProperties(registriesWithProperties(),
List.of("minecraft:oak_log[axis=q]"));
assertEquals(1, messages.size());
assertTrue(messages.get(0).contains("does not accept 'q'"));
assertTrue(messages.get(0).contains("allowed: x, y, z"));
}
@Test
public void validateBlockStatePropertiesAcceptsValidState() {
assertTrue(ContentKeyValidator.validateBlockStateProperties(registriesWithProperties(),
List.of("minecraft:oak_log[axis=z,waterlogged=true]")).isEmpty());
}
@Test
public void validateBlockStatePropertiesSkipsBlocksWithoutDeclaredProperties() {
assertTrue(ContentKeyValidator.validateBlockStateProperties(registriesWithProperties(),
List.of("create:cogwheel[axis=y]", "minecraft:unknown_block[axis=y]")).isEmpty());
}
@Test
public void validateBlockStatePropertiesDedupsRepeatedIssue() {
List<String> messages = ContentKeyValidator.validateBlockStateProperties(registriesWithProperties(),
List.of("minecraft:oak_log[axis=q]", "minecraft:oak_log[axis=q]"));
assertEquals(1, messages.size());
}
@Test
public void validateBlockStatePropertiesReturnsEmptyWithoutPropertyData() {
assertTrue(ContentKeyValidator.validateBlockStateProperties(registries(),
List.of("minecraft:oak_log[axi=y]")).isEmpty());
}
@Test
public void propertySectionOfExtractsStateBody() {
assertEquals("axis=y", ContentKeyValidator.propertySectionOf("minecraft:oak_log[axis=y]"));
assertNull(ContentKeyValidator.propertySectionOf("minecraft:oak_log"));
}
@Test
public void strictContentFollowsSystemProperty() {
String previous = System.getProperty("iris.strictContent");
try {
System.setProperty("iris.strictContent", "true");
assertTrue(ContentKeyValidator.strictContent());
System.setProperty("iris.strictContent", "false");
assertFalse(ContentKeyValidator.strictContent());
} finally {
if (previous == null) {
System.clearProperty("iris.strictContent");
} else {
System.setProperty("iris.strictContent", previous);
}
}
}
private record FakeRegistries(List<String> blocks, List<String> items, List<String> entities,
Map<String, List<PlatformBlockProperty>> properties) implements PlatformRegistries {
@Override
public PlatformBlockState block(String key) {
return null;
@@ -172,7 +290,7 @@ public class ContentKeyValidatorTest {
@Override
public Map<String, List<PlatformBlockProperty>> blockStateProperties() {
return Map.of();
return properties;
}
}
}
@@ -0,0 +1,90 @@
package art.arcane.iris.core.pregenerator;
import art.arcane.volmlib.util.math.Position2;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class PregenTaskBoundsOverflowTest {
@Test
public void farPositiveCenterKeepsRegionBoundsOrdered() {
PregenTask task = PregenTask.builder()
.center(new Position2(Integer.MAX_VALUE - 16, Integer.MAX_VALUE - 16))
.radiusX(4096)
.radiusZ(4096)
.build();
int[] bounds = task.regionBounds();
assertTrue("minX must not exceed maxX", bounds[0] <= bounds[2]);
assertTrue("minZ must not exceed maxZ", bounds[1] <= bounds[3]);
}
@Test
public void farNegativeCenterKeepsRegionBoundsOrdered() {
PregenTask task = PregenTask.builder()
.center(new Position2(Integer.MIN_VALUE + 16, Integer.MIN_VALUE + 16))
.radiusX(4096)
.radiusZ(4096)
.build();
int[] bounds = task.regionBounds();
assertTrue("minX must not exceed maxX", bounds[0] <= bounds[2]);
assertTrue("minZ must not exceed maxZ", bounds[1] <= bounds[3]);
}
@Test
public void hugeRadiusAroundOriginIsRejected() {
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> PregenTask.builder()
.center(new Position2(0, 0))
.radiusX(Integer.MAX_VALUE)
.radiusZ(Integer.MAX_VALUE)
.build());
assertTrue(failure.getMessage().contains("radius 2147483647x2147483647"));
}
@Test
public void worldLimitRadiusIsAccepted() {
PregenTask task = PregenTask.builder()
.center(new Position2(0, 0))
.radiusX(30_000_000)
.radiusZ(30_000_000)
.build();
int[] bounds = task.regionBounds();
assertEquals(-58594, bounds[0]);
assertEquals(58594, bounds[2]);
}
@Test
public void ordinaryBoundsAreUnchanged() {
PregenTask task = PregenTask.builder()
.center(new Position2(0, 0))
.radiusX(1024)
.radiusZ(512)
.build();
assertArrayEqualsMessage(new int[]{-2, -1, 2, 1}, task.regionBounds());
}
@Test
public void clampSaturatesInsteadOfWrapping() {
assertEquals(Integer.MAX_VALUE, PregenTask.clampBlock((long) Integer.MAX_VALUE + 1L));
assertEquals(Integer.MIN_VALUE, PregenTask.clampBlock((long) Integer.MIN_VALUE - 1L));
assertEquals(0, PregenTask.clampBlock(0L));
assertEquals(-7, PregenTask.clampBlock(-7L));
}
private static void assertArrayEqualsMessage(int[] expected, int[] actual) {
assertEquals(expected.length, actual.length);
for (int index = 0; index < expected.length; index++) {
assertEquals("bounds[" + index + "]", expected[index], actual[index]);
}
}
}
@@ -0,0 +1,91 @@
/*
* Iris is a World Generator for Minecraft 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.project;
import art.arcane.iris.engine.object.IrisDecorationStep;
import art.arcane.iris.engine.object.IrisImportedFeatureControl;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
public class ImportedFeatureControlSchemaTest {
@Test
public void everyDecorationStepIsSchemaDescribed() throws NoSuchFieldException {
assertNotNull(IrisDecorationStep.class.getAnnotation(Desc.class));
for (IrisDecorationStep step : IrisDecorationStep.values()) {
assertNotNull(step.name(),
IrisDecorationStep.class.getField(step.name()).getAnnotation(Desc.class));
}
}
@Test
public void controlSchemaExposesEnabledFlagAndStepEnums() {
JSONObject schema = new SchemaBuilder(IrisImportedFeatureControl.class, null).construct();
JSONObject properties = schema.getJSONObject("properties");
assertEquals("boolean", properties.getJSONObject("enabled").getString("type"));
assertEquals("array", properties.getJSONObject("disabled").getString("type"));
assertEquals(1, properties.getJSONObject("disabled").getInt("minItems"));
for (String field : List.of("steps", "disabledSteps")) {
JSONObject list = properties.getJSONObject(field);
assertEquals("array", list.getString("type"));
String definitionKey = list.getJSONObject("items")
.getString("$ref").substring("#/definitions/".length());
JSONArray values = schema.getJSONObject("definitions")
.getJSONObject(definitionKey)
.getJSONArray("oneOf");
List<String> constants = new ArrayList<>();
for (int index = 0; index < values.length(); index++) {
JSONObject entry = values.getJSONObject(index);
constants.add(entry.getString("const"));
assertTrue(field + " " + entry.getString("const"),
entry.getString("description").length() > 0);
}
List<String> expected = new ArrayList<>();
for (IrisDecorationStep step : IrisDecorationStep.values()) {
expected.add(step.name());
}
assertEquals(expected, constants);
}
}
@Test
public void dimensionSchemaCarriesTheControlBlock() {
JSONObject schema = new SchemaBuilder(ControlHolder.class, null).construct();
JSONObject importedFeatures = schema.getJSONObject("properties").getJSONObject("importedFeatures");
assertTrue(importedFeatures.has("$ref") || importedFeatures.has("properties")
|| importedFeatures.has("anyOf"));
}
@Desc("Schema model for the imported feature control block.")
public static class ControlHolder {
@Desc("Controls native placed feature generation.")
private IrisImportedFeatureControl importedFeatures = new IrisImportedFeatureControl();
}
}
@@ -33,10 +33,12 @@ 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.engine.object.annotations.RegistryListBiome;
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.RegistryListSpecialEntity;
import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
@@ -77,9 +79,16 @@ public class SchemaBuilderParityTest {
private static final List<String> ITEM_KEYS = List.of("minecraft:stone", "minecraft:diamond_sword", "cool_mod:ruby");
private static final List<String> ENTITY_KEYS = List.of("minecraft:zombie", "cool_mod:grizzly_bear");
private static final List<String> STRUCTURE_KEYS = List.of("minecraft:monument", "minecraft:stronghold", "cool_mod:sky_temple");
private static final List<String> BIOME_KEYS = List.of("minecraft:plains", "cool_mod:sky_meadow");
private static final List<String> SPECIAL_ENTITY_KEYS = List.of("mythicmobs:skeleton_king");
private static final List<String> EXPECTED_POTIONS = List.of("SPEED", "SLOW_FALLING", "MEGA_BOOST");
private static final List<String> EXPECTED_ENCHANTS = List.of("sharpness", "vorpal");
// Namespaced key first, then the legacy short form for the vanilla namespace only. A mod key is addressable
// by its full key instead of a namespace-stripped path that could collide with vanilla content.
private static final List<String> EXPECTED_POTIONS = List.of(
"minecraft:speed", "SPEED", "minecraft:slow_falling", "SLOW_FALLING", "sniffer_mod:mega_boost");
private static final List<String> EXPECTED_ENCHANTS = List.of(
"minecraft:sharpness", "sharpness", "cool_mod:vorpal");
private static final List<String> EXPECTED_BIOMES = List.of("minecraft:plains", "plains", "cool_mod:sky_meadow");
private static final List<String> EXPECTED_ITEMS = List.of("stone", "diamond_sword", "cool_mod:ruby");
private static final List<String> EXPECTED_ENTITIES = List.of("minecraft:zombie", "cool_mod:grizzly_bear");
@@ -112,6 +121,8 @@ public class SchemaBuilderParityTest {
assertEquals(EXPECTED_ENCHANTS, enumValues(definitions, "enum-enchantment"));
assertEquals(EXPECTED_ITEMS, enumValues(definitions, "enum-item-type"));
assertEquals(EXPECTED_ENTITIES, enumValues(definitions, "enum-entity-type"));
assertEquals(EXPECTED_BIOMES, enumValues(definitions, "enum-biome-type"));
assertEquals(SPECIAL_ENTITY_KEYS, enumValues(definitions, "enum-reg-specialentity"));
}
@Test
@@ -320,6 +331,19 @@ public class SchemaBuilderParityTest {
@Desc("Entity field.")
@RegistryListEntityType
private String entity = "";
@Desc("Biome field.")
@RegistryListBiome
private String biome = "";
@Desc("Biome scatter field.")
@ArrayType(type = String.class)
@RegistryListBiome
private KList<String> biomeScatter = new KList<>();
@Desc("Special entity field.")
@RegistryListSpecialEntity
private String specialEntity = "";
}
@Desc("Independent model.")
@@ -397,7 +421,12 @@ public class SchemaBuilderParityTest {
@Override
public List<String> biomeKeys() {
return List.of();
return BIOME_KEYS;
}
@Override
public List<String> specialEntityKeys() {
return SPECIAL_ENTITY_KEYS;
}
@Override
@@ -0,0 +1,279 @@
/*
* 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.protocol;
import art.arcane.iris.spi.protocol.IrisMessage;
import art.arcane.iris.spi.protocol.IrisMessageCodec;
import art.arcane.iris.spi.protocol.IrisProtocol;
import art.arcane.iris.spi.protocol.ProtocolException;
import org.junit.Test;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
/**
* Exhaustiveness gate for the wire protocol. Three ways a type can rot without any compiler complaint:
* a TYPE_* constant is declared but no record claims it, a permitted record exists but no sample proves it
* survives the codec, and a server-to-client type is defined but nothing on the server ever constructs it.
*
* <p>Producers are checked structurally, from the constant pool: a {@code Methodref} naming
* {@code IrisMessage$X.<init>}. A plain class reference is not enough evidence - the dispatch switch in
* IrisProtocolServer names every inbound record it consumes, so "mentions the class" and "produces the class"
* are different facts.
*/
public class IrisProtocolMessageCoverageTest {
private static final String MESSAGE_OWNER_PREFIX = "art/arcane/iris/spi/protocol/IrisMessage$";
/** One sample per permitted record. Hand written on purpose: the field values are the wire contract. */
private static final List<IrisMessage> SAMPLES = List.of(
new IrisMessage.ClientHello(IrisProtocol.PROTOCOL_VERSION, IrisProtocol.CAPABILITY_VISION),
new IrisMessage.ServerHello(IrisProtocol.PROTOCOL_VERSION, IrisProtocol.CAPABILITY_VISION, "Fabric", true),
new IrisMessage.PregenProgress(1L, 2L, 3L, 4.5D, 6L, IrisMessage.PregenProgress.STATE_RUNNING),
new IrisMessage.PregenEnd(1L, true),
new IrisMessage.DimensionStatus("minecraft:overworld", "overworld", 1337L, -64, 320, true),
new IrisMessage.CursorInfoRequest(16, -32),
new IrisMessage.CursorInfo(16, -32, "iris:plains", "iris:temperate", "", 72, "overworld"),
new IrisMessage.VisionTileRequest(1, 2, 3),
new IrisMessage.VisionTile(1, 2, 3, 4, 0, 1, new byte[]{7, 8}),
new IrisMessage.VisionMarkers(1, 2, 3, List.of(new IrisMessage.VisionMarkers.Marker(4, 5, 6, "spawn"))),
new IrisMessage.PregenRegionDelta(1L, 2, 3, IrisMessage.PregenRegionDelta.STATE_DONE),
new IrisMessage.StudioHotload("overworld", 3, false, ""),
new IrisMessage.Toast(IrisMessage.Toast.KIND_INFO, "Title", "Body"));
/** Server-to-client types the server must be able to construct, and where that construction lives. */
private static final List<Class<?>> SERVER_PRODUCERS = List.of(
IrisProtocolServer.class,
IrisCursorResolver.class,
IrisTileEncoder.class);
private static final Set<Integer> CLIENT_PRODUCED_TYPES = Set.of(
IrisProtocol.TYPE_CLIENT_HELLO,
IrisProtocol.TYPE_CURSOR_INFO_REQUEST,
IrisProtocol.TYPE_VISION_TILE_REQUEST);
/**
* TYPE_VISION_MARKERS has no producer by decision, not by omission: the codec and the client-side overlay
* exist, the server-side marker source is deferred past 1.0. Keeping the type wired both ways means adding
* the producer later is not a wire change. If a producer ever lands, delete this entry.
*/
private static final Set<Integer> PRODUCER_DEFERRED_TYPES = Set.of(IrisProtocol.TYPE_VISION_MARKERS);
@Test
public void everyTypeConstantIsClaimedByExactlyOneRecord() {
Map<Integer, String> declared = declaredTypeConstants();
Map<Integer, String> claimed = new TreeMap<>();
for (IrisMessage sample : SAMPLES) {
String previous = claimed.put(sample.messageTypeId(), sample.getClass().getSimpleName());
assertNull("type id " + sample.messageTypeId() + " claimed twice", previous);
}
assertEquals("TYPE_* constants without a message record, or records without a constant",
declared.keySet(), claimed.keySet());
}
@Test
public void everyPermittedRecordHasASample() {
Set<String> permitted = new LinkedHashSet<>();
for (Class<?> subtype : IrisMessage.class.getPermittedSubclasses()) {
permitted.add(subtype.getSimpleName());
}
Set<String> sampled = new LinkedHashSet<>();
for (IrisMessage sample : SAMPLES) {
sampled.add(sample.getClass().getSimpleName());
}
assertEquals("a permitted IrisMessage record has no coverage sample", permitted, sampled);
}
@Test
public void everySampleRoundTripsThroughTheCodec() throws ProtocolException {
for (IrisMessage sample : SAMPLES) {
byte[] frame = IrisMessageCodec.encode(sample);
assertTrue(sample.getClass().getSimpleName() + " frame exceeds the cap",
frame.length <= IrisProtocol.MAX_FRAME_BYTES);
IrisMessage decoded = IrisMessageCodec.decode(frame);
assertNotNull(sample.getClass().getSimpleName() + " has an encoder but no decoder arm", decoded);
assertEquals(sample.getClass(), decoded.getClass());
assertEquals(sample.messageTypeId(), decoded.messageTypeId());
}
}
@Test
public void everyServerBoundTypeHasAProducerOrIsDeferred() throws IOException {
Set<String> constructed = new LinkedHashSet<>();
for (Class<?> producer : SERVER_PRODUCERS) {
constructed.addAll(constructedMessageRecords(producer));
}
List<String> missing = new ArrayList<>();
List<String> unexpected = new ArrayList<>();
for (IrisMessage sample : SAMPLES) {
String name = sample.getClass().getSimpleName();
int typeId = sample.messageTypeId();
boolean produced = constructed.contains(name);
if (CLIENT_PRODUCED_TYPES.contains(typeId)) {
if (produced) {
unexpected.add(name + " is client-to-server but the server constructs it");
}
continue;
}
if (PRODUCER_DEFERRED_TYPES.contains(typeId)) {
if (produced) {
unexpected.add(name + " now has a server producer; drop it from PRODUCER_DEFERRED_TYPES");
}
continue;
}
if (!produced) {
missing.add(name);
}
}
assertEquals("server-to-client types with no producer in " + SERVER_PRODUCERS, List.of(), missing);
assertEquals("producer direction no longer matches the declared roles", List.of(), unexpected);
}
private static Map<Integer, String> declaredTypeConstants() {
Map<Integer, String> constants = new TreeMap<>();
for (Field field : IrisProtocol.class.getDeclaredFields()) {
if (!field.getName().startsWith("TYPE_")
|| !Modifier.isStatic(field.getModifiers())
|| field.getType() != int.class) {
continue;
}
try {
constants.put(field.getInt(null), field.getName());
} catch (IllegalAccessException unreachable) {
throw new AssertionError("TYPE_* constant is not readable: " + field.getName(), unreachable);
}
}
assertTrue("no TYPE_* constants found on IrisProtocol", constants.size() >= 13);
return constants;
}
/**
* Reads {@code owner} out of every {@code CONSTANT_Methodref} whose name is {@code <init>} and whose owner
* is an IrisMessage record. Bytes only, no class loading, same reason as the core purity gate.
*/
private static Set<String> constructedMessageRecords(Class<?> type) throws IOException {
byte[] bytes;
try (InputStream stream = type.getResourceAsStream(type.getSimpleName() + ".class")) {
assertNotNull("class file missing for " + type.getName(), stream);
bytes = stream.readAllBytes();
}
ConstantPool pool = ConstantPool.read(bytes);
Set<String> constructed = new LinkedHashSet<>();
for (int[] methodref : pool.methodrefs()) {
String owner = pool.className(methodref[0]);
if (owner == null || !owner.startsWith(MESSAGE_OWNER_PREFIX)) {
continue;
}
if (!"<init>".equals(pool.memberName(methodref[1]))) {
continue;
}
constructed.add(owner.substring(MESSAGE_OWNER_PREFIX.length()));
}
return constructed;
}
/**
* The slice of the class-file format this gate needs: UTF8 entries, Class entries, NameAndType entries and
* the Methodref entries that point at them.
*/
private record ConstantPool(String[] utf8, int[] classNameIndex, int[] nameAndTypeNameIndex,
List<int[]> methodrefs) {
private static final int CONSTANT_UTF8 = 1;
private static final int CONSTANT_INTEGER = 3;
private static final int CONSTANT_FLOAT = 4;
private static final int CONSTANT_LONG = 5;
private static final int CONSTANT_DOUBLE = 6;
private static final int CONSTANT_CLASS = 7;
private static final int CONSTANT_STRING = 8;
private static final int CONSTANT_FIELDREF = 9;
private static final int CONSTANT_METHODREF = 10;
private static final int CONSTANT_INTERFACE_METHODREF = 11;
private static final int CONSTANT_NAME_AND_TYPE = 12;
private static final int CONSTANT_METHOD_HANDLE = 15;
private static final int CONSTANT_METHOD_TYPE = 16;
private static final int CONSTANT_DYNAMIC = 17;
private static final int CONSTANT_INVOKE_DYNAMIC = 18;
private static final int CONSTANT_MODULE = 19;
private static final int CONSTANT_PACKAGE = 20;
String className(int index) {
int nameIndex = classNameIndex[index];
return nameIndex == 0 ? null : utf8[nameIndex];
}
String memberName(int nameAndTypeIndex) {
int nameIndex = nameAndTypeNameIndex[nameAndTypeIndex];
return nameIndex == 0 ? null : utf8[nameIndex];
}
static ConstantPool read(byte[] bytes) throws IOException {
DataInputStream in = new DataInputStream(new ByteArrayInputStream(bytes));
if (in.readInt() != 0xCAFEBABE) {
throw new IOException("not a class file");
}
in.readUnsignedShort();
in.readUnsignedShort();
int count = in.readUnsignedShort();
String[] utf8 = new String[count];
int[] classNameIndex = new int[count];
int[] nameAndTypeNameIndex = new int[count];
Map<Integer, int[]> refs = new LinkedHashMap<>();
for (int index = 1; index < count; index++) {
int tag = in.readUnsignedByte();
switch (tag) {
case CONSTANT_UTF8 -> utf8[index] = in.readUTF();
case CONSTANT_CLASS -> classNameIndex[index] = in.readUnsignedShort();
case CONSTANT_NAME_AND_TYPE -> {
nameAndTypeNameIndex[index] = in.readUnsignedShort();
in.readUnsignedShort();
}
case CONSTANT_METHODREF, CONSTANT_INTERFACE_METHODREF ->
refs.put(index, new int[]{in.readUnsignedShort(), in.readUnsignedShort()});
case CONSTANT_INTEGER, CONSTANT_FLOAT, CONSTANT_FIELDREF, CONSTANT_DYNAMIC,
CONSTANT_INVOKE_DYNAMIC -> in.readInt();
case CONSTANT_LONG, CONSTANT_DOUBLE -> {
in.readLong();
index++;
}
case CONSTANT_STRING, CONSTANT_METHOD_TYPE, CONSTANT_MODULE, CONSTANT_PACKAGE ->
in.readUnsignedShort();
case CONSTANT_METHOD_HANDLE -> {
in.readUnsignedByte();
in.readUnsignedShort();
}
default -> throw new IOException("unknown constant pool tag " + tag + " at " + index);
}
}
return new ConstantPool(utf8, classNameIndex, nameAndTypeNameIndex, List.copyOf(refs.values()));
}
}
}
@@ -56,7 +56,7 @@ public class IrisProtocolServerTest {
}
@Test
public void wrongProtocolVersionNeverReachesReady() {
public void wrongProtocolVersionNeverReachesReadyButStillGetsAnswered() {
RecordingTransport transport = new RecordingTransport();
IrisSessionRegistry registry = new IrisSessionRegistry();
IrisProtocolServer server = new IrisProtocolServer(registry, SERVER_CAPABILITIES, BRAND, true);
@@ -67,7 +67,76 @@ public class IrisProtocolServerTest {
assertEquals(IrisSession.State.AWAITING_HELLO, session.state());
assertEquals(1L, server.versionMismatchCount());
assertEquals(0, transport.sent.size());
// The reply is what lets the client resolve to INCOMPATIBLE instead of retrying five times and then
// reporting "server does not run Iris", which is a different and wrong diagnosis.
assertEquals(1, transport.sent.size());
IrisMessage.ServerHello answer = (IrisMessage.ServerHello) transport.sent.get(0);
assertEquals(IrisProtocol.PROTOCOL_VERSION, answer.protocolVersion());
assertEquals(BRAND, answer.serverBrand());
}
@Test
public void mismatchedHelloStillLeavesTheSessionUnableToRequestAnything() {
RecordingTransport transport = new RecordingTransport();
IrisSessionRegistry registry = new IrisSessionRegistry();
IrisProtocolServer server = new IrisProtocolServer(registry, SERVER_CAPABILITIES, BRAND, true);
IrisSession session = new IrisSession("s1", transport);
registry.register(session);
server.setEngineResolver(sessionId -> {
throw new AssertionError("an incompatible session must never reach the engine");
});
server.onClientFrame("s1", IrisMessageCodec.encode(new IrisMessage.ClientHello(IrisProtocol.PROTOCOL_VERSION + 1, IrisProtocol.CAPABILITY_CURSOR)));
server.onClientFrame("s1", IrisMessageCodec.encode(new IrisMessage.CursorInfoRequest(1, 2)));
assertEquals(0L, session.capabilities());
assertEquals(1L, server.droppedBeforeHelloCount());
assertEquals(1, transport.sent.size());
}
@Test
public void cursorRequestBeyondWorldBoundsRejectedWithoutResolvingEngine() {
RecordingTransport transport = new RecordingTransport();
IrisSessionRegistry registry = new IrisSessionRegistry();
IrisProtocolServer server = new IrisProtocolServer(registry, SERVER_CAPABILITIES, BRAND, true);
IrisSession session = new IrisSession("s1", transport);
registry.register(session);
server.onClientFrame("s1", IrisMessageCodec.encode(new IrisMessage.ClientHello(IrisProtocol.PROTOCOL_VERSION, IrisProtocol.CAPABILITY_CURSOR)));
server.setEngineResolver(sessionId -> {
throw new AssertionError("an out-of-bounds column must never reach the engine");
});
server.onClientFrame("s1", IrisMessageCodec.encode(new IrisMessage.CursorInfoRequest(IrisProtocol.MAX_QUERY_BLOCK_COORDINATE + 1, 0)));
server.onClientFrame("s1", IrisMessageCodec.encode(new IrisMessage.CursorInfoRequest(0, -IrisProtocol.MAX_QUERY_BLOCK_COORDINATE - 1)));
server.onClientFrame("s1", IrisMessageCodec.encode(new IrisMessage.CursorInfoRequest(Integer.MIN_VALUE, Integer.MAX_VALUE)));
assertEquals(3L, server.cursorOutOfBoundsCount());
assertEquals(0L, server.cursorInfoServedCount());
assertEquals(1, transport.sent.size());
}
@Test
public void cursorBurstBeyondItsOwnBudgetShedsWithoutTouchingTheFrameBudget() {
RecordingTransport transport = new RecordingTransport();
IrisSessionRegistry registry = new IrisSessionRegistry();
IrisProtocolServer server = new IrisProtocolServer(registry, SERVER_CAPABILITIES, BRAND, true, () -> 1000L);
IrisSession session = new IrisSession("s1", transport);
registry.register(session);
server.onClientFrame("s1", IrisMessageCodec.encode(new IrisMessage.ClientHello(IrisProtocol.PROTOCOL_VERSION, IrisProtocol.CAPABILITY_CURSOR)));
server.setEngineResolver(sessionId -> cursorEngine("iris:plains", "iris:temperate", "", 64, "overworld"));
int overflow = 3;
int total = IrisProtocol.MAX_CURSOR_INFO_REQUESTS_PER_SECOND + overflow;
byte[] request = IrisMessageCodec.encode(new IrisMessage.CursorInfoRequest(8, 8));
for (int index = 0; index < total; index++) {
server.onClientFrame("s1", request);
}
assertEquals(IrisProtocol.MAX_CURSOR_INFO_REQUESTS_PER_SECOND, server.cursorInfoServedCount());
assertEquals(overflow, server.cursorRateLimitedCount());
assertEquals(0L, server.rateLimitedFrameCount());
assertTrue("the cursor budget must be tighter than the frame budget",
IrisProtocol.MAX_CURSOR_INFO_REQUESTS_PER_SECOND < IrisProtocol.MAX_INBOUND_FRAMES_PER_SECOND);
}
@Test
@@ -86,6 +86,61 @@ public class IrisVisionRequestServiceTest {
assertEquals(0L, service.tilesEncodedCount());
}
@Test
public void clearSessionDropsOnlyThatSessionsQueuedRequests() {
IrisSessionRegistry registry = new IrisSessionRegistry();
registerReady(registry, "s1", IrisProtocol.CAPABILITY_VISION);
registerReady(registry, "s10", IrisProtocol.CAPABILITY_VISION);
EngineResolver resolver = sessionId -> {
throw new AssertionError("disabled executor must not process requests");
};
IrisVisionRequestService service = new IrisVisionRequestService(resolver, registry, DISABLED, 8);
service.handle("s1", 0, 0, 0);
service.handle("s1", 1, 0, 0);
service.handle("s10", 0, 0, 0);
assertEquals(3, service.pendingSize());
service.clearSession("s1");
assertEquals(1, service.pendingSize());
assertEquals(0L, service.droppedSaturatedCount());
}
@Test
public void clearSessionIgnoresBlankIdsWithoutTouchingTheQueue() {
IrisSessionRegistry registry = new IrisSessionRegistry();
registerReady(registry, "s1", IrisProtocol.CAPABILITY_VISION);
EngineResolver resolver = sessionId -> {
throw new AssertionError("disabled executor must not process requests");
};
IrisVisionRequestService service = new IrisVisionRequestService(resolver, registry, DISABLED, 8);
service.handle("s1", 0, 0, 0);
service.clearSession(null);
service.clearSession("");
assertEquals(1, service.pendingSize());
}
@Test
public void saturationCountsEveryShedRequestNotJustTheLastBurst() {
IrisSessionRegistry registry = new IrisSessionRegistry();
registerReady(registry, "s1", IrisProtocol.CAPABILITY_VISION);
EngineResolver resolver = sessionId -> {
throw new AssertionError("disabled executor must not process requests");
};
int maxPending = 2;
IrisVisionRequestService service = new IrisVisionRequestService(resolver, registry, DISABLED, maxPending);
for (int index = 0; index < 10; index++) {
service.handle("s1", index, 0, 0);
}
assertEquals(8L, service.droppedSaturatedCount());
assertEquals(maxPending, service.pendingSize());
}
private static CountingTransport registerReady(IrisSessionRegistry registry, String sessionId, long capabilities) {
CountingTransport transport = new CountingTransport();
IrisSession session = new IrisSession(sessionId, transport);
@@ -0,0 +1,143 @@
/*
* Iris is a World Generator for Minecraft 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.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDecorationStep;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisImportedFeatureControl;
import art.arcane.iris.engine.object.NativeFeatureGenerationStatus;
import art.arcane.volmlib.util.collection.KList;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class NativeFeatureGenerationPolicyTest {
private static Engine engineWith(IrisDimension dimension) {
Engine engine = mock(Engine.class);
when(engine.getDimension()).thenReturn(dimension);
return engine;
}
@Test
public void dimensionDefaultsToFeaturesOff() {
Engine engine = engineWith(new IrisDimension());
assertFalse(NativeFeatureGenerationPolicy.isEnabled(engine));
assertFalse(NativeFeatureGenerationPolicy.shouldGenerateStep(engine,
IrisDecorationStep.UNDERGROUND_ORES));
assertEquals(NativeFeatureGenerationStatus.FEATURES_DISABLED,
NativeFeatureGenerationPolicy.resolve(engine, "minecraft:ore_diamond",
IrisDecorationStep.UNDERGROUND_ORES));
}
@Test
public void enabledDimensionResolvesThroughItsControl() {
KList<String> disabled = new KList<>();
disabled.add("minecraft:trees");
IrisDimension dimension = new IrisDimension();
dimension.setImportedFeatures(new IrisImportedFeatureControl()
.setEnabled(true)
.setDisabled(disabled));
Engine engine = engineWith(dimension);
assertTrue(NativeFeatureGenerationPolicy.isEnabled(engine));
assertEquals(NativeFeatureGenerationStatus.GENERATE_NATIVE,
NativeFeatureGenerationPolicy.resolve(engine, "minecraft:ore_diamond",
IrisDecorationStep.UNDERGROUND_ORES));
assertEquals(NativeFeatureGenerationStatus.DISABLED_BY_PACK,
NativeFeatureGenerationPolicy.resolve(engine, "minecraft:trees_plains",
IrisDecorationStep.VEGETAL_DECORATION));
}
@Test
public void missingEngineOrDimensionFailsLoudly() {
assertTrue(assertThrows(NullPointerException.class,
() -> NativeFeatureGenerationPolicy.control(null))
.getMessage().contains("requires an engine"));
assertTrue(assertThrows(NullPointerException.class,
() -> NativeFeatureGenerationPolicy.control(engineWith(null)))
.getMessage().contains("requires a bound dimension"));
}
/**
* The field initializer gives every dimension a control block, but an explicit
* {@code "importedFeatures": null} in dimension JSON overwrites it - Gson assigns what the document says. This
* policy is consulted per feature decision on the generation path, so a stray null must disable native features,
* not fail the chunk.
*/
@Test
public void nullControlBlockDisablesNativeFeaturesInsteadOfFailingGeneration() {
IrisDimension dimension = new IrisDimension();
dimension.setImportedFeatures(null);
Engine engine = engineWith(dimension);
assertFalse(NativeFeatureGenerationPolicy.isEnabled(engine));
assertFalse(NativeFeatureGenerationPolicy.shouldGenerateStep(engine, IrisDecorationStep.VEGETAL_DECORATION));
assertEquals(NativeFeatureGenerationStatus.FEATURES_DISABLED,
NativeFeatureGenerationPolicy.resolve(engine, "minecraft:ore_diamond",
IrisDecorationStep.UNDERGROUND_ORES));
}
@Test
public void generationStatusMessagesAreSharedAcrossPlatforms() {
assertEquals("Native feature minecraft:ore_diamond is disabled by this dimension's"
+ " importedFeatures.disabled list.",
NativeFeatureGenerationPolicy.generationStatusMessage("minecraft:ore_diamond",
NativeFeatureGenerationStatus.DISABLED_BY_PACK));
assertEquals("Native feature minecraft:ore_diamond does not generate because this dimension's"
+ " importedFeatures.enabled is false.",
NativeFeatureGenerationPolicy.generationStatusMessage("minecraft:ore_diamond",
NativeFeatureGenerationStatus.FEATURES_DISABLED));
assertEquals("Native feature minecraft:ore_diamond does not generate because its decoration step is"
+ " excluded by importedFeatures.",
NativeFeatureGenerationPolicy.generationStatusMessage("minecraft:ore_diamond",
NativeFeatureGenerationStatus.STEP_DISABLED));
}
/**
* The generation-settings getter on both platforms maps a custom biome onto the owning Iris biome's
* vanilla derivative key. This is that resolution rule, which is what decides whose features an Iris
* custom biome inherits.
*/
@Test
public void customBiomeSettingsFollowTheVanillaDerivativeKey() {
IrisBiome derivativeOnly = new IrisBiome();
derivativeOnly.setDerivative("minecraft:plains");
assertEquals("minecraft:plains", derivativeOnly.getVanillaDerivativeKey());
IrisBiome overridden = new IrisBiome();
overridden.setDerivative("minecraft:plains");
overridden.setVanillaDerivative("minecraft:desert");
assertEquals("minecraft:desert", overridden.getVanillaDerivativeKey());
IrisBiome unnamespaced = new IrisBiome();
unnamespaced.setDerivative("forest");
assertEquals("minecraft:forest", unnamespaced.getVanillaDerivativeKey());
IrisBiome modded = new IrisBiome();
modded.setDerivative("somemod:alien_waste");
assertEquals("somemod:alien_waste", modded.getVanillaDerivativeKey());
}
}
@@ -0,0 +1,108 @@
/*
* Iris is a World Generator for Minecraft 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.volmlib.util.collection.KList;
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 IrisBiomeCustomTagInheritanceTest {
private static KList<String> tags(String... values) {
KList<String> list = new KList<>();
for (String value : values) {
list.add(value);
}
return list;
}
@Test
public void customBiomeInheritsTheVanillaDerivativeTagsOnTopOfAuthorTags() {
IrisBiomeCustom biome = new IrisBiomeCustom()
.setTags(tags("mymod:is_spooky"));
KList<String> resolved = biome.getEffectiveTags("minecraft:plains");
assertEquals("mymod:is_spooky", resolved.get(0));
assertTrue(resolved.contains("minecraft:is_overworld"));
// stronghold_biased_to is not inherited: strongholds.json reads it as preferred_biomes, so inheriting it
// would enter every derived Iris biome into vanilla's stronghold ring around spawn.
assertFalse(resolved.contains("minecraft:stronghold_biased_to"));
}
@Test
public void unknownDerivativeContributesNothingButKeepsAuthorTags() {
IrisBiomeCustom biome = new IrisBiomeCustom()
.setTags(tags("mymod:is_spooky"));
assertEquals(List.of("mymod:is_spooky"), biome.getEffectiveTags("somemod:alien_waste"));
assertEquals(List.of("mymod:is_spooky"), biome.getEffectiveTags(null));
assertEquals(List.of("mymod:is_spooky"), biome.getEffectiveTags(""));
}
@Test
public void noAuthorTagsStillInheritsAndNeverReturnsNull() {
IrisBiomeCustom biome = new IrisBiomeCustom();
KList<String> resolved = biome.getEffectiveTags("minecraft:warm_ocean");
assertTrue(resolved.contains("minecraft:is_ocean"));
assertTrue(resolved.contains("minecraft:produces_corals_from_bonemeal"));
assertFalse(resolved.isEmpty());
assertTrue(biome.getEffectiveTags(null).isEmpty());
}
@Test
public void duplicateTagsCollapse() {
IrisBiomeCustom biome = new IrisBiomeCustom()
.setTags(tags("minecraft:is_overworld", "minecraft:is_overworld"));
KList<String> resolved = biome.getEffectiveTags("minecraft:plains");
assertEquals(1, resolved.stream().filter("minecraft:is_overworld"::equals).count());
}
@Test
public void structureTagsAreNeverInherited() {
// Native structure placement resolves through the biome's structure derivative, so pulling a custom
// biome into a has_structure tag would place the structure twice.
for (String biomeKey : List.of("minecraft:plains", "minecraft:desert", "minecraft:deep_ocean",
"minecraft:nether_wastes", "minecraft:the_end")) {
for (String tag : IrisVanillaBiomeTags.tagsFor(biomeKey)) {
assertFalse(biomeKey + " -> " + tag, tag.contains("has_structure"));
}
}
}
@Test
public void tagTableCoversTheVanillaDimensionFamilies() {
assertTrue(IrisVanillaBiomeTags.knownBiomeCount() >= 60);
assertTrue(IrisVanillaBiomeTags.tagsFor("minecraft:plains").contains("minecraft:is_overworld"));
assertTrue(IrisVanillaBiomeTags.tagsFor("minecraft:nether_wastes").contains("minecraft:is_nether"));
assertTrue(IrisVanillaBiomeTags.tagsFor("minecraft:the_end").contains("minecraft:is_end"));
// Case and namespace normalisation: pack authors write both forms.
assertEquals(IrisVanillaBiomeTags.tagsFor("minecraft:plains"),
IrisVanillaBiomeTags.tagsFor("PLAINS"));
assertTrue(IrisVanillaBiomeTags.tagsFor("minecraft:nowhere_at_all").isEmpty());
}
}
@@ -1,6 +1,8 @@
package art.arcane.iris.engine.object;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.collection.KMap;
import art.arcane.volmlib.util.collection.KSet;
import art.arcane.volmlib.util.json.JSONArray;
import art.arcane.volmlib.util.json.JSONObject;
import org.junit.Rule;
@@ -10,6 +12,7 @@ import org.junit.rules.TemporaryFolder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -22,9 +25,8 @@ public class IrisDimensionBiomeTagTest {
public void biomeTagWritesAreSortedAndDeduplicated() throws Exception {
Path output = temporaryFolder.getRoot().toPath().resolve("allows_surface_slime_spawns.json");
IrisDimension.writeBiomeTag(output, "overworld:swamp_b");
IrisDimension.writeBiomeTag(output, "overworld:swamp_a");
IrisDimension.writeBiomeTag(output, "overworld:swamp_b");
IrisDimension.writeBiomeTag(output, Set.of("overworld:swamp_b"));
IrisDimension.writeBiomeTag(output, Set.of("overworld:swamp_a", "overworld:swamp_b"));
JSONObject tag = new JSONObject(Files.readString(output, StandardCharsets.UTF_8));
JSONArray values = tag.getJSONArray("values");
@@ -38,7 +40,9 @@ public class IrisDimensionBiomeTagTest {
public void customBiomeTagUsesTheMinecraftBiomeTagPath() throws Exception {
KList<String> tags = new KList<>();
tags.add("minecraft:allows_surface_slime_spawns");
IrisDimension.installBiomeTags(temporaryFolder.getRoot(), "overworld:swamp", tags);
KMap<String, KSet<String>> membership = new KMap<>();
IrisDimension.collectBiomeTags(membership, "overworld:swamp", tags);
IrisDimension.installBiomeTags(temporaryFolder.getRoot(), membership);
Path output = temporaryFolder.getRoot().toPath()
.resolve("data/minecraft/tags/worldgen/biome/allows_surface_slime_spawns.json");
@@ -46,4 +50,28 @@ public class IrisDimensionBiomeTagTest {
assertEquals("overworld:swamp", tag.getJSONArray("values").getString(0));
}
@Test
public void accumulatedTagsAreWrittenOncePerTagAndMergeWithExistingFiles() throws Exception {
KList<String> tags = new KList<>();
tags.add("minecraft:is_overworld");
KMap<String, KSet<String>> first = new KMap<>();
IrisDimension.collectBiomeTags(first, "overworld:swamp", tags);
IrisDimension.collectBiomeTags(first, "overworld:plains", tags);
IrisDimension.installBiomeTags(temporaryFolder.getRoot(), first);
KMap<String, KSet<String>> second = new KMap<>();
IrisDimension.collectBiomeTags(second, "nether:ash", tags);
IrisDimension.installBiomeTags(temporaryFolder.getRoot(), second);
Path output = temporaryFolder.getRoot().toPath()
.resolve("data/minecraft/tags/worldgen/biome/is_overworld.json");
JSONArray values = new JSONObject(Files.readString(output, StandardCharsets.UTF_8))
.getJSONArray("values");
assertEquals(3, values.length());
assertEquals("nether:ash", values.getString(0));
assertEquals("overworld:plains", values.getString(1));
assertEquals("overworld:swamp", values.getString(2));
}
}
@@ -0,0 +1,186 @@
/*
* Iris is a World Generator for Minecraft 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.volmlib.util.collection.KList;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class IrisImportedFeatureControlTest {
private static KList<String> keys(String... values) {
KList<String> list = new KList<>();
for (String value : values) {
list.add(value);
}
return list;
}
private static KList<IrisDecorationStep> steps(IrisDecorationStep... values) {
KList<IrisDecorationStep> list = new KList<>();
for (IrisDecorationStep value : values) {
list.add(value);
}
return list;
}
@Test
public void defaultControlIsOffAndGeneratesNothing() {
IrisImportedFeatureControl control = new IrisImportedFeatureControl();
assertFalse(control.isEnabled());
assertFalse(control.shouldGenerateFeatures());
assertFalse(control.shouldGenerate("minecraft:ore_diamond"));
assertFalse(control.shouldGenerateStep(IrisDecorationStep.UNDERGROUND_ORES));
assertEquals(NativeFeatureGenerationStatus.FEATURES_DISABLED,
control.resolve("minecraft:ore_diamond", IrisDecorationStep.UNDERGROUND_ORES));
}
@Test
public void enabledControlGeneratesEveryStepAndKeyByDefault() {
IrisImportedFeatureControl control = new IrisImportedFeatureControl().setEnabled(true);
assertTrue(control.shouldGenerateFeatures());
assertTrue(control.shouldGenerate("minecraft:ore_diamond"));
assertTrue(control.shouldGenerate("somemod:weird_ore"));
for (IrisDecorationStep step : IrisDecorationStep.values()) {
assertTrue(step.name(), control.shouldGenerateStep(step));
}
assertEquals(NativeFeatureGenerationStatus.GENERATE_NATIVE,
control.resolve("minecraft:ore_diamond", IrisDecorationStep.UNDERGROUND_ORES));
}
@Test
public void disabledKeyPrefixMatchesOnFamilyBoundariesOnly() {
IrisImportedFeatureControl control = new IrisImportedFeatureControl()
.setEnabled(true)
.setDisabled(keys("minecraft:ore"));
assertFalse(control.shouldGenerate("minecraft:ore_diamond"));
assertFalse(control.shouldGenerate("minecraft:ore"));
assertFalse(control.shouldGenerate("minecraft:ore/deep"));
assertTrue(control.shouldGenerate("minecraft:orebody"));
assertTrue(control.shouldGenerate("somemod:ore_diamond"));
assertEquals(NativeFeatureGenerationStatus.DISABLED_BY_PACK,
control.resolve("minecraft:ore_diamond", IrisDecorationStep.UNDERGROUND_ORES));
}
@Test
public void stepAllowListNarrowsGenerationToListedStepsOnly() {
IrisImportedFeatureControl control = new IrisImportedFeatureControl()
.setEnabled(true)
.setSteps(steps(IrisDecorationStep.UNDERGROUND_ORES));
assertTrue(control.shouldGenerateStep(IrisDecorationStep.UNDERGROUND_ORES));
assertFalse(control.shouldGenerateStep(IrisDecorationStep.VEGETAL_DECORATION));
assertFalse(control.shouldGenerateStep(IrisDecorationStep.LAKES));
assertEquals(NativeFeatureGenerationStatus.STEP_DISABLED,
control.resolve("minecraft:trees_plains", IrisDecorationStep.VEGETAL_DECORATION));
assertEquals(NativeFeatureGenerationStatus.GENERATE_NATIVE,
control.resolve("minecraft:ore_diamond", IrisDecorationStep.UNDERGROUND_ORES));
}
@Test
public void disabledStepsWinOverTheAllowList() {
IrisImportedFeatureControl control = new IrisImportedFeatureControl()
.setEnabled(true)
.setSteps(steps(IrisDecorationStep.UNDERGROUND_ORES, IrisDecorationStep.VEGETAL_DECORATION))
.setDisabledSteps(steps(IrisDecorationStep.VEGETAL_DECORATION));
assertTrue(control.shouldGenerateStep(IrisDecorationStep.UNDERGROUND_ORES));
assertFalse(control.shouldGenerateStep(IrisDecorationStep.VEGETAL_DECORATION));
}
@Test
public void unknownStepGeneratesUnlessAnAllowListNarrowedGeneration() {
IrisImportedFeatureControl open = new IrisImportedFeatureControl().setEnabled(true);
IrisImportedFeatureControl narrowed = new IrisImportedFeatureControl()
.setEnabled(true)
.setSteps(steps(IrisDecorationStep.UNDERGROUND_ORES));
assertTrue(open.shouldGenerateStep(null));
assertFalse(narrowed.shouldGenerateStep(null));
}
@Test
public void nullStepInResolveSkipsTheStepGate() {
IrisImportedFeatureControl control = new IrisImportedFeatureControl()
.setEnabled(true)
.setSteps(steps(IrisDecorationStep.UNDERGROUND_ORES));
assertEquals(NativeFeatureGenerationStatus.GENERATE_NATIVE,
control.resolve("minecraft:trees_plains", null));
}
@Test
public void blankKeyIsInvalidRatherThanGenerated() {
IrisImportedFeatureControl control = new IrisImportedFeatureControl().setEnabled(true);
assertEquals(NativeFeatureGenerationStatus.INVALID_REGISTRY_KEY, control.resolve(null, null));
assertEquals(NativeFeatureGenerationStatus.INVALID_REGISTRY_KEY, control.resolve(" ", null));
assertFalse(control.shouldGenerate(null));
}
@Test
public void nullCollectionsFailLoudlyNamingTheField() {
IrisImportedFeatureControl nullDisabled = new IrisImportedFeatureControl()
.setEnabled(true).setDisabled(null);
IrisImportedFeatureControl nullSteps = new IrisImportedFeatureControl()
.setEnabled(true).setSteps(null);
IrisImportedFeatureControl nullDisabledSteps = new IrisImportedFeatureControl()
.setEnabled(true).setDisabledSteps(null);
assertTrue(assertThrows(NullPointerException.class,
() -> nullDisabled.shouldGenerate("minecraft:ore_diamond"))
.getMessage().contains("importedFeatures.disabled"));
assertTrue(assertThrows(NullPointerException.class,
() -> nullSteps.shouldGenerateStep(IrisDecorationStep.LAKES))
.getMessage().contains("importedFeatures.steps"));
assertTrue(assertThrows(NullPointerException.class,
() -> nullDisabledSteps.shouldGenerateStep(IrisDecorationStep.LAKES))
.getMessage().contains("importedFeatures.disabledSteps"));
}
@Test
public void decorationStepOrdinalsMatchTheVanillaTable() {
// Verified against MC 26.2 GenerationStep.Decoration. The platform converts by ordinal, so a drift
// here silently mislabels every step gate.
String[] expected = {
"raw_generation", "lakes", "local_modifications", "underground_structures",
"surface_structures", "strongholds", "underground_ores", "underground_decoration",
"fluid_springs", "vegetal_decoration", "top_layer_modification"
};
assertEquals(expected.length, IrisDecorationStep.values().length);
for (int ordinal = 0; ordinal < expected.length; ordinal++) {
IrisDecorationStep step = IrisDecorationStep.byOrdinal(ordinal);
assertEquals(expected[ordinal], step.getSerializedName());
assertEquals(ordinal, step.ordinal());
assertEquals(step, IrisDecorationStep.byKey(expected[ordinal]));
assertEquals(step, IrisDecorationStep.byKey(step.name()));
}
assertEquals(null, IrisDecorationStep.byOrdinal(expected.length));
assertEquals(null, IrisDecorationStep.byOrdinal(-1));
assertEquals(null, IrisDecorationStep.byKey("not_a_step"));
assertEquals(null, IrisDecorationStep.byKey(null));
}
}
@@ -0,0 +1,50 @@
/*
* 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 org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
/**
* The modded item translator reads loot keys through these accessors instead of reflecting on IrisLoot fields, so
* they are part of the platform-neutral contract.
*/
public class IrisLootKeyAccessorTest {
@Test
public void typeKeyReturnsAuthoredKeyVerbatim() {
IrisLoot loot = new IrisLoot();
loot.setType("mymod:ruby_sword");
assertEquals("mymod:ruby_sword", loot.getTypeKey());
}
@Test
public void typeKeyDefaultsToEmptyString() {
assertEquals("", new IrisLoot().getTypeKey());
}
@Test
public void dyeColorKeyReturnsAuthoredValueAndNullWhenUnset() {
IrisLoot loot = new IrisLoot();
assertNull(loot.getDyeColorKey());
loot.setDyeColor("LIGHT_BLUE");
assertEquals("LIGHT_BLUE", loot.getDyeColorKey());
}
}
@@ -0,0 +1,81 @@
/*
* 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 org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class IrisObjectPaletteScanTest {
@Rule
public TemporaryFolder folder = new TemporaryFolder();
@Test
public void readPaletteKeysReturnsV2PaletteWithoutResolvingStates() throws IOException {
File file = writeObject("v2.iob", true, "minecraft:stone", "minecraft:oak_log[axis=y]");
assertEquals(List.of("minecraft:stone", "minecraft:oak_log[axis=y]"), IrisObjectIO.readPaletteKeys(file));
}
@Test
public void readPaletteKeysReturnsEmptyForLegacyHeader() throws IOException {
File file = writeObject("legacy.iob", false, "minecraft:stone");
assertTrue(IrisObjectIO.readPaletteKeys(file).isEmpty());
}
@Test
public void readPaletteKeysReturnsEmptyForTruncatedFile() throws IOException {
File file = folder.newFile("truncated.iob");
try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) {
out.writeInt(1);
out.writeInt(1);
}
assertTrue(IrisObjectIO.readPaletteKeys(file).isEmpty());
}
@Test
public void readPaletteKeysReturnsEmptyForMissingFile() {
assertTrue(IrisObjectIO.readPaletteKeys(new File(folder.getRoot(), "absent.iob")).isEmpty());
}
private File writeObject(String name, boolean v2Header, String... palette) throws IOException {
File file = folder.newFile(name);
try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) {
out.writeInt(3);
out.writeInt(3);
out.writeInt(3);
out.writeUTF(v2Header ? "Iris V2 IOB;" : "Iris V1 IOB;");
out.writeShort(palette.length);
for (String key : palette) {
out.writeUTF(key);
}
out.writeInt(0);
out.writeInt(0);
}
return file;
}
}
@@ -0,0 +1,104 @@
package art.arcane.iris.purity;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* A classloader that behaves like a Fabric/Forge/NeoForge JVM: {@code org.bukkit.**} and
* {@code io.papermc.paper.**} do not exist, no matter that paper-api sits on the test classpath.
* <p>
* Two properties make it a real gate rather than a decoration:
* <ol>
* <li>Bukkit and Paper are refused outright - the app classloader is never consulted, so
* parent-first delegation cannot leak Paper in through the back door.</li>
* <li>Every {@code art.arcane.iris.**} class (except this test-support package) and every
* {@code art.arcane.volmlib.**} class is <em>defined by this loader</em> from the parent's
* class bytes. Definition, not delegation, is what routes all of the class's own symbol
* resolution - supertypes, field types, annotation values, everything the JVM links lazily -
* back through the filter. Delegating these to the parent would have them resolve org.bukkit
* happily and the gate would pass even on code that cannot load on a mod loader.</li>
* </ol>
* VolmLib is self-defined for the same reason Iris is: pack types hold VolmLib values ({@code
* IrisMatterObject} holds a {@code Matter}), VolmLib's matter slicers reference a dozen Bukkit types,
* and VolmLib decides at runtime whether to install them by probing for {@code org.bukkit.Bukkit}.
* Delegated to the parent, that probe sees paper-api and answers "yes" - the exact opposite of what
* happens on a mod loader, so the gate would exercise the Bukkit branch it is supposed to forbid.
* <p>
* Everything else (JDK, gson, fastutil, ...) delegates to the parent normally; the parent is also
* used purely as a byte source for the classes this loader defines itself.
*/
public final class BukkitHidingClassLoader extends ClassLoader {
private static final String[] HIDDEN_PREFIXES = {"org.bukkit.", "io.papermc.paper."};
private static final String[] SELF_DEFINE_PREFIXES = {"art.arcane.iris.", "art.arcane.volmlib."};
private static final String TEST_SUPPORT_PREFIX = "art.arcane.iris.purity.";
public BukkitHidingClassLoader(ClassLoader parent) {
super("bukkit-hiding", parent);
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (isHidden(name)) {
throw new ClassNotFoundException("hidden by the Bukkit purity gate: " + name);
}
synchronized (getClassLoadingLock(name)) {
Class<?> loaded = findLoadedClass(name);
if (loaded == null) {
loaded = shouldSelfDefine(name) ? define(name) : getParent().loadClass(name);
}
if (resolve) {
resolveClass(loaded);
}
return loaded;
}
}
private boolean isHidden(String name) {
if (name.equals("org.bukkit.Bukkit")) {
return true;
}
for (String prefix : HIDDEN_PREFIXES) {
if (name.startsWith(prefix)) {
return true;
}
}
return false;
}
private boolean shouldSelfDefine(String name) {
if (name.startsWith(TEST_SUPPORT_PREFIX)) {
return false;
}
for (String prefix : SELF_DEFINE_PREFIXES) {
if (name.startsWith(prefix)) {
return true;
}
}
return false;
}
private Class<?> define(String name) throws ClassNotFoundException {
byte[] bytes = readClassBytes(getParent(), name);
if (bytes == null) {
throw new ClassNotFoundException(name);
}
return defineClass(name, bytes, 0, bytes.length);
}
/** Reads the raw class file for {@code name} off {@code source}'s resource path. */
public static byte[] readClassBytes(ClassLoader source, String name) {
String resource = name.replace('.', '/') + ".class";
try (InputStream in = source.getResourceAsStream(resource)) {
if (in == null) {
return null;
}
ByteArrayOutputStream out = new ByteArrayOutputStream(16384);
in.transferTo(out);
return out.toByteArray();
} catch (IOException e) {
return null;
}
}
}
@@ -0,0 +1,238 @@
package art.arcane.iris.purity;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Minimal, dependency-free class-file reader. Only the structural facts the purity gate needs:
* the annotation descriptors on the class, the supertype chain names, and the declared fields.
* <p>
* Reading bytes instead of reflecting is deliberate: {@code Class#getDeclaredFields()} and
* {@code Class#getInterfaces()} resolve the types they describe, so on a classloader that hides
* org.bukkit they throw NoClassDefFoundError before they can tell you which member was at fault.
* The parser can name the member.
*/
public final class ClassFileFacts {
private static final int CONSTANT_UTF8 = 1;
private static final int CONSTANT_INTEGER = 3;
private static final int CONSTANT_FLOAT = 4;
private static final int CONSTANT_LONG = 5;
private static final int CONSTANT_DOUBLE = 6;
private static final int CONSTANT_CLASS = 7;
private static final int CONSTANT_STRING = 8;
private static final int CONSTANT_FIELDREF = 9;
private static final int CONSTANT_METHODREF = 10;
private static final int CONSTANT_INTERFACE_METHODREF = 11;
private static final int CONSTANT_NAME_AND_TYPE = 12;
private static final int CONSTANT_METHOD_HANDLE = 15;
private static final int CONSTANT_METHOD_TYPE = 16;
private static final int CONSTANT_DYNAMIC = 17;
private static final int CONSTANT_INVOKE_DYNAMIC = 18;
private static final int CONSTANT_MODULE = 19;
private static final int CONSTANT_PACKAGE = 20;
private static final int ACC_STATIC = 0x0008;
private static final int ACC_TRANSIENT = 0x0080;
/**
* A declared field: raw JVM descriptor plus the generic {@code Signature} attribute when the
* compiler emitted one.
* <p>
* Both matter and they fail at different moments. The descriptor is erased, and it is what
* {@code Class#getDeclaredFields()} resolves - eagerly, for every field including transient ones -
* so a Bukkit type there makes the whole class unloadable. The signature carries the type
* arguments the descriptor threw away ({@code KList<BlockData>} erases to {@code KList}), and Gson
* resolves those through {@code Field#getGenericType()} for every field it actually walks, which
* is every non-static, non-transient field.
*/
public record DeclaredField(String name, String descriptor, String signature, boolean isStatic, boolean isTransient) {
}
private final String internalName;
private final String superName;
private final List<String> interfaceNames;
private final List<DeclaredField> fields;
private final Set<String> classAnnotationDescriptors;
private ClassFileFacts(String internalName,
String superName,
List<String> interfaceNames,
List<DeclaredField> fields,
Set<String> classAnnotationDescriptors) {
this.internalName = internalName;
this.superName = superName;
this.interfaceNames = Collections.unmodifiableList(interfaceNames);
this.fields = Collections.unmodifiableList(fields);
this.classAnnotationDescriptors = Collections.unmodifiableSet(classAnnotationDescriptors);
}
public String internalName() {
return internalName;
}
public String binaryName() {
return internalName.replace('/', '.');
}
public String superName() {
return superName;
}
public List<String> interfaceNames() {
return interfaceNames;
}
public List<DeclaredField> fields() {
return fields;
}
public boolean hasClassAnnotation(String descriptor) {
return classAnnotationDescriptors.contains(descriptor);
}
public static ClassFileFacts read(byte[] bytes) throws IOException {
DataInputStream in = new DataInputStream(new ByteArrayInputStream(bytes));
int magic = in.readInt();
if (magic != 0xCAFEBABE) {
throw new IOException("Not a class file (magic " + Integer.toHexString(magic) + ")");
}
in.readUnsignedShort();
in.readUnsignedShort();
int constantPoolCount = in.readUnsignedShort();
String[] utf8 = new String[constantPoolCount];
int[] classNameIndex = new int[constantPoolCount];
for (int i = 1; i < constantPoolCount; i++) {
int tag = in.readUnsignedByte();
switch (tag) {
case CONSTANT_UTF8 -> utf8[i] = in.readUTF();
case CONSTANT_INTEGER, CONSTANT_FLOAT, CONSTANT_FIELDREF, CONSTANT_METHODREF,
CONSTANT_INTERFACE_METHODREF, CONSTANT_NAME_AND_TYPE, CONSTANT_DYNAMIC,
CONSTANT_INVOKE_DYNAMIC -> in.readInt();
case CONSTANT_LONG, CONSTANT_DOUBLE -> {
in.readLong();
i++;
}
case CONSTANT_CLASS -> classNameIndex[i] = in.readUnsignedShort();
case CONSTANT_STRING, CONSTANT_METHOD_TYPE, CONSTANT_MODULE, CONSTANT_PACKAGE ->
in.readUnsignedShort();
case CONSTANT_METHOD_HANDLE -> {
in.readUnsignedByte();
in.readUnsignedShort();
}
default -> throw new IOException("Unknown constant pool tag " + tag + " at " + i);
}
}
in.readUnsignedShort();
int thisClass = in.readUnsignedShort();
int superClass = in.readUnsignedShort();
String internalName = utf8[classNameIndex[thisClass]];
String superName = superClass == 0 ? null : utf8[classNameIndex[superClass]];
int interfaceCount = in.readUnsignedShort();
List<String> interfaceNames = new ArrayList<>(interfaceCount);
for (int i = 0; i < interfaceCount; i++) {
interfaceNames.add(utf8[classNameIndex[in.readUnsignedShort()]]);
}
int fieldCount = in.readUnsignedShort();
List<DeclaredField> fields = new ArrayList<>(fieldCount);
for (int i = 0; i < fieldCount; i++) {
int accessFlags = in.readUnsignedShort();
String name = utf8[in.readUnsignedShort()];
String descriptor = utf8[in.readUnsignedShort()];
String signature = readAttributes(in, utf8, null);
fields.add(new DeclaredField(name, descriptor, signature,
(accessFlags & ACC_STATIC) != 0, (accessFlags & ACC_TRANSIENT) != 0));
}
int methodCount = in.readUnsignedShort();
for (int i = 0; i < methodCount; i++) {
in.readUnsignedShort();
in.readUnsignedShort();
in.readUnsignedShort();
readAttributes(in, utf8, null);
}
Set<String> classAnnotations = new LinkedHashSet<>();
readAttributes(in, utf8, classAnnotations);
return new ClassFileFacts(internalName, superName, interfaceNames, fields, classAnnotations);
}
/**
* Walks an attributes table, optionally collecting annotation descriptors, and returns the
* {@code Signature} attribute's value when the table carries one.
*/
private static String readAttributes(DataInputStream in, String[] utf8, Set<String> annotationSink) throws IOException {
String signature = null;
int count = in.readUnsignedShort();
for (int i = 0; i < count; i++) {
String attributeName = utf8[in.readUnsignedShort()];
int length = in.readInt();
byte[] payload = in.readNBytes(length);
if (payload.length != length) {
throw new IOException("Truncated attribute " + attributeName);
}
if ("Signature".equals(attributeName) && payload.length == 2) {
signature = utf8[((payload[0] & 0xFF) << 8) | (payload[1] & 0xFF)];
}
if (annotationSink != null
&& ("RuntimeVisibleAnnotations".equals(attributeName) || "RuntimeInvisibleAnnotations".equals(attributeName))) {
collectAnnotationDescriptors(payload, utf8, annotationSink);
}
}
return signature;
}
/**
* Reads only the top-level annotation type descriptors out of an annotations attribute. Member
* values are skipped structurally rather than parsed, because the gate only asks "is this class
* annotated with @Snippet".
*/
private static void collectAnnotationDescriptors(byte[] payload, String[] utf8, Set<String> sink) throws IOException {
DataInputStream in = new DataInputStream(new ByteArrayInputStream(payload));
int annotationCount = in.readUnsignedShort();
for (int i = 0; i < annotationCount; i++) {
sink.add(utf8[in.readUnsignedShort()]);
skipElementValuePairs(in, utf8);
}
}
private static void skipElementValuePairs(DataInputStream in, String[] utf8) throws IOException {
int pairCount = in.readUnsignedShort();
for (int i = 0; i < pairCount; i++) {
in.readUnsignedShort();
skipElementValue(in, utf8);
}
}
private static void skipElementValue(DataInputStream in, String[] utf8) throws IOException {
int tag = in.readUnsignedByte();
switch (tag) {
case 'B', 'C', 'D', 'F', 'I', 'J', 'S', 'Z', 's', 'c' -> in.readUnsignedShort();
case 'e' -> {
in.readUnsignedShort();
in.readUnsignedShort();
}
case '@' -> {
in.readUnsignedShort();
skipElementValuePairs(in, utf8);
}
case '[' -> {
int length = in.readUnsignedShort();
for (int i = 0; i < length; i++) {
skipElementValue(in, utf8);
}
}
default -> throw new IOException("Unknown element value tag " + (char) tag);
}
}
}
@@ -0,0 +1,519 @@
package art.arcane.iris.purity;
import art.arcane.iris.engine.object.annotations.Snippet;
import org.junit.Test;
import java.io.File;
import java.lang.annotation.Annotation;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Modifier;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/**
* Structural purity gate for everything Gson touches in a pack.
* <p>
* The text ratchet in {@code core/build.gradle} ('bukkitPurityRatchet') greps for the string
* "org.bukkit" per file. It cannot see the two failure shapes that actually break the modded
* loaders, because both are produced by the compiler rather than written by hand:
* <ul>
* <li>Lombok generating {@code equals}/{@code hashCode}/{@code toString} against a hand-written
* getter that shadows the field accessor and returns a resolved Bukkit type, e.g.
* {@code IrisBiome.getDerivative() -> org.bukkit.block.Biome}. The generated bytecode then
* references org.bukkit even though the field is a String. Closed globally by
* {@code lombok.config}: {@code equalsAndHashCode.doNotUseGetters} + {@code toString.doNotUseGetters}.</li>
* <li>A raw Bukkit-typed field. Gson walks {@code getDeclaredFields()} and the JVM resolves every
* field type there, so one such field takes the whole pack type down.</li>
* </ul>
* So this test loads each pack type on a classloader where {@code org.bukkit.**} does not exist and
* runs the Gson-shaped operations against it.
* <p>
* <b>Observed pre-fix failures (WP4 baseline):</b> {@code IrisBiome} and {@code IrisEffect}
* (getter-shadowed equals/hashCode/toString), {@code IrisBiomeCustomSpawn} and
* {@code IrisBiomeCustomParticle} (same shape), {@code TileData} (raw {@code org.bukkit.Material}
* field plus a {@code toString} routed through {@code KeyedType} -> {@code NamespacedKey}),
* {@code IrisVanillaLootTable} (raw {@code org.bukkit.loot.LootTable} field),
* {@code IrisVillagerTrade} (three raw {@code org.bukkit.inventory.ItemStack} fields; class
* deleted) and {@code Particles} (Bukkit registry lookups in a static initializer).
* <p>
* <b>Deliberately not asserted:</b> method and constructor descriptors. These classes legitimately
* expose a Bukkit-typed edge that only the Bukkit adapter calls - {@code IrisEntity.spawn(Engine,
* Location)}, {@code IrisLoot.get(...) -> ItemStack}, {@code IrisObjectRotation.rotate(BlockData,
* ...)}, {@code TileData.toBukkit(Block)} and ~60 more. Method descriptors are resolved at
* invocation, never at class load, field walk or equals/hashCode/toString, so they are not a modded
* boot hazard. Fields and supertypes are, and both are asserted hard below.
*/
public class PackTypeBukkitPurityGateTest {
private static final String SNIPPET_DESCRIPTOR = "Lart/arcane/iris/engine/object/annotations/Snippet;";
private static final String BUKKIT_INTERNAL_PREFIX = "org/bukkit/";
/** See {@link #theGateStillCatchesAClassThatNeedsBukkit()}. */
private static final String BUKKIT_DEPENDENT_CANARY = "art.arcane.iris.engine.object.IrisObjectRotation$Faces";
private static final String OBJECT_PACKAGE = "art/arcane/iris/engine/object";
private static final Path IRIS_DATA_SOURCE =
Paths.get("src", "main", "java", "art", "arcane", "iris", "core", "loader", "IrisData.java");
/**
* Every type handed to {@code registerLoader} in {@code IrisData.hotloaded()} - i.e. every root
* type Gson deserializes from a pack. Kept literal on purpose; {@link
* #registeredRootListMatchesIrisData()} fails if the source drifts away from it.
*/
private static final List<String> GSON_REGISTERED_ROOTS = List.of(
"art.arcane.iris.engine.object.IrisLootTable",
"art.arcane.iris.engine.object.IrisSpawner",
"art.arcane.iris.engine.object.IrisEntity",
"art.arcane.iris.engine.object.IrisRegion",
"art.arcane.iris.engine.object.IrisBiome",
"art.arcane.iris.engine.object.IrisMod",
"art.arcane.iris.engine.object.IrisDimension",
"art.arcane.iris.engine.object.IrisGenerator",
"art.arcane.iris.engine.object.IrisMarker",
"art.arcane.iris.engine.object.IrisBlockData",
"art.arcane.iris.engine.object.IrisExpression",
"art.arcane.iris.engine.object.IrisObject",
"art.arcane.iris.engine.object.IrisImage",
"art.arcane.iris.engine.object.matter.IrisMatterObject",
"art.arcane.iris.engine.object.IrisStructure",
"art.arcane.iris.engine.object.IrisJigsawPool",
"art.arcane.iris.engine.object.IrisJigsawPiece");
/**
* Types reachable from a pack that are not roots and carry no {@code @Snippet}, but are
* deserialized/serialized all the same (object tile payloads, vanilla loot table adapters).
*/
private static final List<String> ADDITIONAL_PACK_TYPES = List.of(
"art.arcane.iris.engine.object.TileData",
"art.arcane.iris.engine.object.LegacyTileData",
"art.arcane.iris.engine.object.IrisVanillaLootTable");
/**
* Bukkit-registry constant holders that a core pack type references, so their class initializer
* runs on the modded loaders too. {@code Particles} is statically imported by {@code IrisEntity}.
* ({@code Materials} and {@code Attributes} have the same shape but are only reachable from the
* Bukkit adapter, so they stay out of the gate.)
*/
private static final List<String> BUKKIT_STATIC_HOLDERS = List.of(
"art.arcane.iris.util.common.data.registry.Particles");
@Test
public void theGateActuallyHidesBukkit() throws Exception {
ClassLoader app = getClass().getClassLoader();
assertNotNull("paper-api must be on the test classpath for this gate to mean anything",
Class.forName("org.bukkit.Material", false, app));
BukkitHidingClassLoader hiding = new BukkitHidingClassLoader(app);
assertThrows("the hiding loader must refuse org.bukkit even though paper-api is present",
ClassNotFoundException.class,
() -> Class.forName("org.bukkit.Material", false, hiding));
assertThrows(ClassNotFoundException.class,
() -> Class.forName("org.bukkit.Bukkit", false, hiding));
Class<?> selfDefined = Class.forName("art.arcane.iris.engine.object.IrisPosition", false, hiding);
assertEquals("iris classes must be defined by the hiding loader, not delegated to the parent",
hiding, selfDefined.getClassLoader());
}
@Test
public void registeredRootListMatchesIrisData() throws Exception {
if (!Files.isRegularFile(IRIS_DATA_SOURCE)) {
return;
}
String source = Files.readString(IRIS_DATA_SOURCE, StandardCharsets.UTF_8);
Matcher matcher = Pattern.compile("registerLoader\\((\\w+)\\.class").matcher(source);
Set<String> inSource = new TreeSet<>();
while (matcher.find()) {
inSource.add(matcher.group(1));
}
Set<String> inTest = new TreeSet<>();
for (String name : GSON_REGISTERED_ROOTS) {
inTest.add(name.substring(name.lastIndexOf('.') + 1));
}
assertEquals("IrisData registers a different set of pack roots than this gate covers - "
+ "add the new type to GSON_REGISTERED_ROOTS", inTest, inSource);
}
@Test
public void bukkitStaticHoldersInitializeWithoutBukkit() {
BukkitHidingClassLoader hiding = new BukkitHidingClassLoader(getClass().getClassLoader());
List<String> failures = new ArrayList<>();
for (String name : BUKKIT_STATIC_HOLDERS) {
try {
Class.forName(name, true, hiding);
} catch (Throwable e) {
if (mentionsBukkit(e)) {
failures.add(name + "#<clinit>: " + describe(e));
}
}
}
assertPure(failures);
}
@Test
public void packTypesLoadAndBehaveWithoutBukkit() throws Exception {
BukkitHidingClassLoader hiding = new BukkitHidingClassLoader(getClass().getClassLoader());
List<String> gateTypes = gateTypes();
assertTrue("expected the @Snippet scan to find the pack snippet types, found only "
+ gateTypes.size(), gateTypes.size() > 60);
List<String> failures = new ArrayList<>();
List<String> skipped = new ArrayList<>();
for (String name : gateTypes) {
Class<?> type;
try {
type = Class.forName(name, true, hiding);
} catch (Throwable e) {
record(failures, skipped, name + "#<clinit>", e);
continue;
}
// Gson step 1: walk the declared fields and their annotations. Both resolve types.
Field[] declared;
try {
declared = type.getDeclaredFields();
for (Field field : declared) {
field.getType();
// Gson resolves the generic type of every field it walks, and the type arguments a
// descriptor erased are resolved there and nowhere else - a KList<BlockData> field is as
// fatal as a raw BlockData one. Static and transient fields are excluded before Gson ever
// asks for their generic type, so they are excluded here too.
int modifiers = field.getModifiers();
if (!Modifier.isStatic(modifiers) && !Modifier.isTransient(modifiers)) {
field.getGenericType();
}
for (Annotation annotation : field.getDeclaredAnnotations()) {
annotation.annotationType();
}
}
for (Annotation annotation : type.getDeclaredAnnotations()) {
annotation.annotationType();
}
} catch (Throwable e) {
record(failures, skipped, name + "#getDeclaredFields", e);
continue;
}
// Gson step 2: construct via the no-arg constructor where one exists.
Constructor<?> noArg;
try {
noArg = type.getDeclaredConstructor();
} catch (NoSuchMethodException e) {
noArg = null;
} catch (Throwable e) {
record(failures, skipped, name + "#getDeclaredConstructor", e);
continue;
}
if (noArg == null) {
continue;
}
Object first;
Object second;
try {
noArg.setAccessible(true);
first = noArg.newInstance();
second = noArg.newInstance();
} catch (Throwable e) {
record(failures, skipped, name + "#<init>", e);
continue;
}
// Lombok step: the generated members that used to bake in Bukkit getters.
try {
first.equals(second);
} catch (Throwable e) {
record(failures, skipped, name + "#equals", e);
}
try {
first.hashCode();
second.hashCode();
} catch (Throwable e) {
record(failures, skipped, name + "#hashCode", e);
}
try {
first.toString();
} catch (Throwable e) {
record(failures, skipped, name + "#toString", e);
}
}
// A failure that does not name org.bukkit is still a failure. Every pack type must load, walk and
// compare headlessly with no server at all - that is what a mod loader's dedicated server does, and
// it is what the studio and every test does too. Nothing on this list is tolerated: the whole gate
// set currently gets through with zero skips, so a skip means new code broke something.
assertTrue("Bukkit purity gate: " + skipped.size() + " pack member(s) failed for a non-Bukkit reason. "
+ "These still cannot be Gson-walked on a headless server - fix them or explain the "
+ "exemption here:" + join(skipped),
skipped.isEmpty());
assertPure(failures);
}
/**
* Negative control. Every other assertion here passes when the gate is working <em>and</em> when it is
* silently broken - a classloader that quietly delegates org.bukkit to the parent, a
* {@link #mentionsBukkit(Throwable)} that stopped matching, a class scan that found nothing. This test
* fails in exactly that case, by pointing the machinery at a class that provably cannot initialize
* without Bukkit.
* <p>
* {@code IrisObjectRotation$Faces} is a private lazy holder whose initializer reads
* {@code org.bukkit.block.BlockFace} constants. It is deliberately <em>not</em> in the gate set: the holder
* exists precisely so that the Bukkit-edge rotation methods can touch BlockFace without dragging it into
* the enclosing pack type's own initializer. That makes it the ideal control - a real class, in the pack
* package, that the gate must be able to catch.
*/
@Test
public void theGateStillCatchesAClassThatNeedsBukkit() {
BukkitHidingClassLoader hiding = new BukkitHidingClassLoader(getClass().getClassLoader());
List<String> failures = new ArrayList<>();
List<String> skipped = new ArrayList<>();
// The assertion has to live outside the catch: this method's own catch is a catch(Throwable), and an
// AssertionError raised inside it would be swallowed and then recorded as a Bukkit failure, because the
// failure message itself names org.bukkit. The control would pass in the one case it exists to catch.
Throwable error = null;
try {
Class.forName(BUKKIT_DEPENDENT_CANARY, true, hiding);
} catch (Throwable e) {
error = e;
}
assertNotNull(BUKKIT_DEPENDENT_CANARY + " initialized with org.bukkit hidden - the gate is not hiding "
+ "Bukkit any more, so every other assertion in this class is vacuous", error);
record(failures, skipped, BUKKIT_DEPENDENT_CANARY + "#<clinit>", error);
assertTrue("the canary failed for a non-Bukkit reason, so the gate would no longer recognize a real "
+ "Bukkit dependency as one:" + join(skipped), skipped.isEmpty());
assertEquals("the canary must be recorded as a Bukkit purity failure", 1, failures.size());
}
/**
* Second negative control, for the generic-signature half of
* {@link #packTypeFieldsAndSupertypesDeclareNoBukkit()}. Every pack type currently passes that check, so a
* parser regression that stopped reading the {@code Signature} attribute would look exactly like success.
* {@code IrisBiome#derivativeResolved} is the reference case: descriptor erased to a bare AtomicCache, type
* argument {@code org.bukkit.block.Biome}, exempt from the check only because it is transient - which is what
* every resolved-value cache in a pack type looks like.
*/
@Test
public void theGenericSignatureScanSeesWhatTheDescriptorErased() throws Exception {
byte[] bytes = BukkitHidingClassLoader.readClassBytes(getClass().getClassLoader(),
"art.arcane.iris.engine.object.IrisBiome");
assertNotNull("no class bytes for IrisBiome", bytes);
ClassFileFacts.DeclaredField cache = null;
for (ClassFileFacts.DeclaredField field : ClassFileFacts.read(bytes).fields()) {
if (field.name().equals("derivativeResolved")) {
cache = field;
break;
}
}
assertNotNull("IrisBiome#derivativeResolved is the reference case for the generic-signature scan - "
+ "if it was renamed, point this test at another AtomicCache of a Bukkit type", cache);
assertFalse("the erased descriptor cannot name the Bukkit type argument, which is the whole reason the "
+ "signature is read: " + cache.descriptor(), cache.descriptor().contains(BUKKIT_INTERNAL_PREFIX));
assertNotNull("no Signature attribute was read for a generic field - the scan is vacuous", cache.signature());
assertTrue("the Signature attribute must name the erased Bukkit type argument, got " + cache.signature(),
cache.signature().contains(BUKKIT_INTERNAL_PREFIX));
assertTrue("this field is exempt only because it is transient - Gson never resolves its generic type",
cache.isTransient());
}
private static String join(List<String> lines) {
StringBuilder out = new StringBuilder();
for (String line : lines) {
out.append("\n ").append(line);
}
return out.toString();
}
@Test
public void packTypeFieldsAndSupertypesDeclareNoBukkit() throws Exception {
ClassLoader app = getClass().getClassLoader();
List<String> failures = new ArrayList<>();
for (String name : gateTypes()) {
byte[] bytes = BukkitHidingClassLoader.readClassBytes(app, name);
assertNotNull("no class bytes for " + name, bytes);
ClassFileFacts facts = ClassFileFacts.read(bytes);
if (facts.superName() != null && facts.superName().startsWith(BUKKIT_INTERNAL_PREFIX)) {
failures.add(name + ": extends " + facts.superName());
}
for (String iface : facts.interfaceNames()) {
if (iface.startsWith(BUKKIT_INTERNAL_PREFIX)) {
failures.add(name + ": implements " + iface);
}
}
for (ClassFileFacts.DeclaredField field : facts.fields()) {
if (field.isStatic()) {
continue;
}
// The raw descriptor is resolved by getDeclaredFields() for every declared field, transient
// included, so it must be Bukkit-free unconditionally.
if (field.descriptor().contains(BUKKIT_INTERNAL_PREFIX)) {
failures.add(name + "#field " + field.name() + ": " + field.descriptor()
+ " (store the namespaced key as a String and resolve it at the Bukkit edge)");
}
// The generic signature carries what the descriptor erased. Gson resolves it through
// getGenericType(), but only for the fields it walks - transient fields are excluded first.
if (!field.isTransient() && field.signature() != null
&& field.signature().contains(BUKKIT_INTERNAL_PREFIX)) {
failures.add(name + "#field " + field.name() + ": " + field.signature()
+ " (a Bukkit type argument is resolved by Gson's getGenericType() even though the "
+ "descriptor erased it - hold the neutral type, or mark the field transient if it is a cache)");
}
}
}
assertPure(failures);
}
private static void record(List<String> failures, List<String> skipped, String member, Throwable e) {
if (mentionsBukkit(e)) {
failures.add(member + ": " + describe(e));
} else {
skipped.add(member + ": " + describe(e));
}
}
private static void assertPure(List<String> failures) {
if (failures.isEmpty()) {
return;
}
StringBuilder message = new StringBuilder("Bukkit purity gate failed on ")
.append(failures.size())
.append(" member(s) - these cannot load on Fabric/Forge/NeoForge:");
for (String failure : failures) {
message.append("\n ").append(failure);
}
fail(message.toString());
}
/**
* True when anything in the throwable chain names org.bukkit. That is the discriminator between
* "this type is not modded-safe" and "the default instance of this type happens to be awkward to
* build in a unit test", which keeps the gate from becoming a general instantiability test.
*/
private static boolean mentionsBukkit(Throwable error) {
Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
for (Throwable current = error; current != null && seen.add(current); current = current.getCause()) {
String message = current.getMessage();
if (message != null) {
String normalized = message.replace('/', '.').toLowerCase(Locale.ROOT);
if (normalized.contains("org.bukkit")) {
return true;
}
}
for (Throwable suppressed : current.getSuppressed()) {
if (mentionsBukkit(suppressed)) {
return true;
}
}
}
return false;
}
private static String describe(Throwable error) {
Throwable root = error;
if (root instanceof InvocationTargetException && root.getCause() != null) {
root = root.getCause();
}
StringBuilder out = new StringBuilder(root.getClass().getName());
if (root.getMessage() != null) {
out.append(": ").append(root.getMessage());
}
Throwable cause = root.getCause();
if (cause != null && cause != root) {
out.append(" <- ").append(cause.getClass().getSimpleName());
if (cause.getMessage() != null) {
out.append(": ").append(cause.getMessage());
}
}
return out.toString();
}
/** The Gson roots, the extra pack-reachable types, and every {@code @Snippet} class. */
private static List<String> gateTypes() throws Exception {
Set<String> types = new LinkedHashSet<>(GSON_REGISTERED_ROOTS);
types.addAll(ADDITIONAL_PACK_TYPES);
types.addAll(snippetTypes());
return new ArrayList<>(types);
}
private static List<String> snippetTypes() throws Exception {
ClassLoader app = PackTypeBukkitPurityGateTest.class.getClassLoader();
List<String> snippets = new ArrayList<>();
for (String candidate : classNamesUnder(OBJECT_PACKAGE)) {
if (candidate.indexOf('$') >= 0) {
continue;
}
byte[] bytes = BukkitHidingClassLoader.readClassBytes(app, candidate);
if (bytes == null) {
continue;
}
if (ClassFileFacts.read(bytes).hasClassAnnotation(SNIPPET_DESCRIPTOR)) {
snippets.add(candidate);
}
}
Collections.sort(snippets);
assertFalse("@Snippet class scan found nothing - is " + Snippet.class.getName()
+ " still runtime-retained?", snippets.isEmpty());
return snippets;
}
private static List<String> classNamesUnder(String packageInternalName) throws Exception {
assertNotNull("no code source for " + Snippet.class.getName() + " - cannot enumerate pack types",
Snippet.class.getProtectionDomain().getCodeSource());
URL location = Snippet.class.getProtectionDomain().getCodeSource().getLocation();
Path root = Paths.get(location.toURI());
List<String> names = new ArrayList<>();
if (Files.isDirectory(root)) {
Path directory = root.resolve(packageInternalName);
if (!Files.isDirectory(directory)) {
return names;
}
try (Stream<Path> walk = Files.walk(directory)) {
walk.filter(path -> path.toString().endsWith(".class")).forEach(path -> {
String relative = root.relativize(path).toString().replace(File.separatorChar, '/');
names.add(relative.substring(0, relative.length() - ".class".length()).replace('/', '.'));
});
}
return names;
}
try (ZipFile zip = new ZipFile(root.toFile())) {
zip.stream()
.map(ZipEntry::getName)
.filter(entry -> entry.startsWith(packageInternalName + "/") && entry.endsWith(".class"))
.forEach(entry -> names.add(
entry.substring(0, entry.length() - ".class".length()).replace('/', '.')));
}
return names;
}
}