diff --git a/README.md b/README.md index 810d2350b..a8a88f8cf 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,29 @@ workspaces with schema autocomplete over the server's live registries, entity sp including death loot, pregeneration with a boss bar or client HUD, the `/iris` command tree, and the goldenhash determinism gate, which is interchangeable across all four platforms. +### Native worldgen over Iris terrain + +Iris replaces the chunk generator, so vanilla and mod worldgen only runs where Iris runs it. This +is identical on every platform. + +| Vanilla / mod worldgen | Over Iris terrain | Control | +|---|---|---| +| Structures (vanilla, datapack, mod) | Yes, on by default | `importedStructures.disabled` denies individual keys | +| Placed features: ores, trees, plants, springs, geodes | Yes, **off by default** | `importedFeatures.enabled` per dimension, with per-step and per-key filters | +| Carvers (caves, canyons, mod carvers) | Never - architectural | Iris has no `NoiseGeneratorSettings` for a carver to sample; use pack `caves`/`carvings` | +| Surface builders and surface rules | Never | Iris builds its surface from pack palettes | +| Mod biomes | Only as a `derivative`, `vanillaDerivative`, `biomeScatter` or `biomeSkyScatter` target | Iris chooses biomes from the pack, not from a biome source | +| Mob spawning, including mod mobs | Yes | Biome spawn tables are merged with the vanilla derivative's | + +With `importedFeatures` off - the default - chunk output is byte-for-byte what Iris has always +produced. See [docs/api/modded.md](docs/api/modded.md) for the full control reference, including +which `pointed_dripstone` keys the 26.2 `speleothem` rename does and does not affect. + +Independently of that flag, Iris custom biomes now inherit the biome tags of their vanilla +derivative on every platform, so the emitted datapack tag files change. Anything driven by biome +tags therefore applies to Iris custom biomes: mob variants, spawn rules, and any vanilla or mod +content selecting on `#minecraft:is_overworld` and friends. + ## Install **Plugin (Paper/Purpur/Leaf/Canvas/Folia/Spigot):** drop the plugin jar into `plugins/` and start @@ -42,7 +65,7 @@ the server. On first boot Iris downloads the default `overworld` pack automatica self-contained (core, SPI, and required Fabric API modules are bundled). On first boot Iris downloads the default `overworld` pack before the worldgen datapack is written, so the default pack is fully active immediately. Packs installed later register their custom dimension types -(height ranges) and custom biomes through the forced datapack at server start — restart once after +(height ranges) and custom biomes through the forced datapack at server start - restart once after adding a pack so worlds get its full heights and biomes; worlds created before that restart run with fallback heights. @@ -91,7 +114,7 @@ console/status output. `/iris pregen status` reports progress on the plugin. ## Studio and VSCode workspace The studio is the pack authoring environment, available on all platforms. Studio worlds are -transient — they are deleted on close and purged at startup. +transient - they are deleted on close and purged at startup. ``` /iris studio create [template] scaffold a new pack (default template: example) @@ -142,8 +165,8 @@ returns `---`. A real zero returns `0`. The world values are the surface reading at the player's block column. Walking refreshes them at most once per second per player, so a whole board of `world.*` keys costs one refresh per player per second no matter how many of them are on it, and a value may lag a sprinting player by up to a -second. A jump that is not walking — joining, respawning, changing worlds, stepping through a portal, -or any teleport including `/iris goto`, `/tp`, an ender pearl and a random teleport — is published +second. A jump that is not walking - joining, respawning, changing worlds, stepping through a portal, +or any teleport including `/iris goto`, `/tp`, an ender pearl and a random teleport - is published immediately, so a player who arrives somewhere and then stands still never keeps reading the biome, region or dimension of where they came from. `pregen.*` is global: there is one pregeneration job per server, and `%iris_pregen.world%` says which world it is. diff --git a/adapters/bukkit/nms/v26_2_R1/logs/latest.log b/adapters/bukkit/nms/v26_2_R1/logs/latest.log index 07846ea5c..574b5ddf4 100644 --- a/adapters/bukkit/nms/v26_2_R1/logs/latest.log +++ b/adapters/bukkit/nms/v26_2_R1/logs/latest.log @@ -1,3 +1,3 @@ -[23:25:34] [Test worker/INFO]: [STDERR]: [Iris/WARN] Native structure terrain envelope at 0,0 was clipped to Minecraft's 8-chunk structure reference range -[23:25:34] [Test worker/INFO]: [STDERR]: [Iris/WARN] Native structure burial at 0,0 clamped to world floor: wanted -19, used -4 -[23:25:34] [Test worker/INFO]: [STDERR]: [Iris/WARN] Native structure burial at 0,0 clamped to world floor: wanted -5, used -2 +[20:01:46] [Test worker/INFO]: [STDERR]: [Iris/WARN] Native structure terrain envelope at 0,0 was clipped to Minecraft's 8-chunk structure reference range +[20:01:46] [Test worker/INFO]: [STDERR]: [Iris/WARN] Native structure burial at 0,0 clamped to world floor: wanted -19, used -4 +[20:01:46] [Test worker/INFO]: [STDERR]: [Iris/WARN] Native structure burial at 0,0 clamped to world floor: wanted -5, used -2 diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java new file mode 100644 index 000000000..96c94540f --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/ImportedFeatureStage.java @@ -0,0 +1,393 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.NativeFeatureGenerationPolicy; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +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.spi.IrisLogging; +import it.unimi.dsi.fastutil.ints.IntArraySet; +import it.unimi.dsi.fastutil.ints.IntSet; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.Registry; +import net.minecraft.core.SectionPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeGenerationSettings; +import net.minecraft.world.level.biome.FeatureSorter; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.RandomSupport; +import net.minecraft.world.level.levelgen.WorldgenRandom; +import net.minecraft.world.level.levelgen.XoroshiroRandomSource; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Bukkit twin of the modded imported-feature stage. Same control, same semantics, same seeds: with + * {@code importedFeatures.enabled} the vanilla placed-feature pass runs over Iris terrain, and with the + * control off nothing here allocates or runs. + * + *

Iris does its own native structure pass and calls the delegate with {@code addVanillaDecorations=false}, + * so the vanilla feature half never runs on its own. This reproduces that half only - structures are never + * placed twice. + * + *

Threading: called from {@code applyBiomeDecoration} on the worldgen thread that owns the chunk. The + * FEATURES chunk step is not parallel-safe. + */ +final class ImportedFeatureStage { + private static final String CYCLE_MARKER = "Feature order cycle found"; + + private final Engine engine; + private volatile FeatureTable featureTable; + private volatile IrisDimension inertDimension; + + ImportedFeatureStage(Engine engine) { + this.engine = engine; + } + + /** + * Generation settings for one biome holder, mapping Iris custom biomes onto their vanilla derivative. + * Pass-through while the control is off, which is what keeps {@code BiomeFilter} behaving exactly as it + * does today. + */ + BiomeGenerationSettings generationSettings(Holder biome) { + FeatureTable table = featureTable; + if (table == null) { + return null; + } + Holder mapped = table.derivatives().get(holderKey(biome)); + return mapped == null ? null : mapped.value().getGenerationSettings(); + } + + /** + * Builds the table on the first decorated chunk, or leaves the stage inert. A feature-order cycle is + * reported once here and degrades to features-off; it never reaches chunk generation as a crash. + * + *

The volatile fast path is unlocked, so a prepared stage costs two reads per chunk. The build is + * synchronized: every worldgen thread decorating a chunk calls this, and two threads that both found the + * stage unprepared would each run {@code FeatureSorter}, whose cycle detection is the expensive part. + */ + void prepare(WorldGenLevel level) { + IrisDimension dimension = engine.getDimension(); + if (settled(dimension)) { + return; + } + synchronized (this) { + if (settled(dimension)) { + return; + } + build(level, dimension); + } + } + + private boolean settled(IrisDimension dimension) { + FeatureTable current = featureTable; + if (current != null && current.dimension() == dimension) { + return true; + } + return inertDimension == dimension; + } + + private void build(WorldGenLevel level, IrisDimension dimension) { + IrisImportedFeatureControl control; + try { + control = NativeFeatureGenerationPolicy.control(engine); + } catch (RuntimeException error) { + IrisLogging.error("Iris could not read importedFeatures for this dimension; features off: " + + error); + markInert(dimension); + return; + } + if (!control.shouldGenerateFeatures()) { + markInert(dimension); + return; + } + FeatureTable built; + try { + built = buildTable(level, control, dimension); + } catch (Throwable error) { + IrisLogging.error("Iris importedFeatures is off for " + dimensionKey() + + ": feature table construction failed: " + error); + markInert(dimension); + return; + } + if (built == null) { + markInert(dimension); + return; + } + featureTable = built; + inertDimension = null; + IrisLogging.info("Iris importedFeatures on for " + dimensionKey() + ": " + built.biomes().size() + + " biomes, " + built.steps().size() + " steps, " + built.derivatives().size() + + " custom-biome derivative maps"); + } + + private void markInert(IrisDimension dimension) { + featureTable = null; + inertDimension = dimension; + } + + private FeatureTable buildTable(WorldGenLevel level, IrisImportedFeatureControl control, + IrisDimension dimension) { + Registry registry = level.registryAccess().lookupOrThrow(Registries.BIOME); + Set visibleKeys = visibleBiomeKeys(); + // Registry-ordered walk, never a hash-ordered set: FeatureSorter's cycle detection walks this list and + // an unordered walk makes detection depend on JVM hash order. + List> biomes = new ArrayList<>(); + Map> byKey = new HashMap<>(); + registry.listElements().forEach((Holder.Reference reference) -> { + String key = holderKey(reference); + if (key != null && visibleKeys.contains(key)) { + biomes.add(reference); + byKey.put(key, reference); + } + }); + if (biomes.isEmpty()) { + IrisLogging.error("Iris importedFeatures is on but " + dimensionKey() + + " exposes no registered biomes; features off"); + return null; + } + Map> derivatives = customBiomeDerivatives(registry, byKey); + List steps; + try { + steps = FeatureSorter.buildFeaturesPerStep(biomes, + (Holder biome) -> settingsFor(biome, derivatives).features(), true); + } catch (IllegalStateException error) { + String message = error.getMessage(); + if (message == null || !message.contains(CYCLE_MARKER)) { + throw error; + } + IrisLogging.error("Iris importedFeatures is off for " + dimensionKey() + + ": the registered placed features cannot be ordered. " + message + + ". Remove or reorder the conflicting content, or leave importedFeatures.enabled false."); + return null; + } + boolean filtered = control.getDisabled() != null && !control.getDisabled().isEmpty(); + return new FeatureTable(dimension, control, List.copyOf(biomes), Set.copyOf(biomes), + Map.copyOf(byKey), steps, Map.copyOf(derivatives), filtered); + } + + /** + * Every biome key Iris can write into a chunk section: the structure derivative, the raw derivative, every + * scatter entry, and every generated custom biome. + */ + private Set visibleBiomeKeys() { + Set keys = new LinkedHashSet<>(); + String namespace = engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT); + for (IrisBiome irisBiome : engine.getAllBiomes()) { + addKey(keys, irisBiome.getStructureDerivativeKey()); + addKey(keys, irisBiome.getDerivativeKey()); + addKey(keys, irisBiome.getVanillaDerivativeKey()); + for (String scatter : irisBiome.getBiomeScatter()) { + addKey(keys, scatter); + } + for (String scatter : irisBiome.getBiomeSkyScatter()) { + addKey(keys, scatter); + } + if (!irisBiome.isCustom()) { + continue; + } + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + addKey(keys, namespace + ":" + customBiome.getId()); + } + } + return keys; + } + + /** + * Maps every generated Iris custom biome key onto the registry holder of its Iris biome's vanilla + * derivative. The custom biome's own datapack JSON carries no features by design. + */ + private Map> customBiomeDerivatives(Registry registry, + Map> byKey) { + Map> derivatives = new HashMap<>(); + String namespace = engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT); + for (IrisBiome irisBiome : engine.getAllBiomes()) { + if (!irisBiome.isCustom()) { + continue; + } + String derivativeKey = normalizeKey(irisBiome.getVanillaDerivativeKey()); + // Resolve from the registry, not only from the visible biome set: a sea or shore biome's structure + // derivative is rewritten away from its vanilla derivative, so the derivative whose features we + // want is not always a biome Iris can emit. + Holder derivative = byKey.get(derivativeKey); + if (derivative == null) { + derivative = resolveHolder(registry, derivativeKey); + } + if (derivative == null) { + IrisLogging.warn("Iris importedFeatures: vanilla derivative " + derivativeKey + " of biome " + + irisBiome.getLoadKey() + + " is not registered; its custom biomes generate no imported features"); + continue; + } + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + derivatives.put(namespace + ":" + customBiome.getId().toLowerCase(Locale.ROOT), derivative); + } + } + return derivatives; + } + + private static Holder resolveHolder(Registry registry, String key) { + if (registry == null || key == null || key.isBlank()) { + return null; + } + Identifier identifier = Identifier.tryParse(key); + if (identifier == null) { + return null; + } + return registry.get(identifier).>map((Holder.Reference reference) -> reference) + .orElse(null); + } + + private static BiomeGenerationSettings settingsFor(Holder biome, + Map> derivatives) { + Holder mapped = derivatives.get(holderKey(biome)); + return mapped == null + ? biome.value().getGenerationSettings() + : mapped.value().getGenerationSettings(); + } + + /** + * Runs the vanilla placed-feature pass for one chunk on the calling worldgen thread. A no-op while the + * control is disabled or the table degraded. + */ + void run(WorldGenLevel level, ChunkAccess chunk, ChunkGenerator owner) { + FeatureTable table = featureTable; + if (table == null) { + return; + } + ChunkPos centerPos = chunk.getPos(); + SectionPos sectionPos = SectionPos.of(centerPos, level.getMinSectionY()); + BlockPos origin = sectionPos.origin(); + Registry featureRegistry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE); + List steps = table.steps(); + WorldgenRandom random = new WorldgenRandom(new XoroshiroRandomSource(RandomSupport.generateUniqueSeed())); + long decorationSeed = random.setDecorationSeed(level.getSeed(), origin.getX(), origin.getZ()); + Set> chunkBiomes = chunkBiomes(level, sectionPos, table); + + try { + for (int stepIndex = 0; stepIndex < steps.size(); stepIndex++) { + if (!table.control().shouldGenerateStep(IrisDecorationStep.byOrdinal(stepIndex))) { + continue; + } + placeStep(level, table, steps.get(stepIndex), featureRegistry, chunkBiomes, owner, + random, decorationSeed, origin, stepIndex); + } + } catch (Throwable error) { + throw new IllegalStateException("Iris imported feature placement failed for chunk " + + centerPos.x() + "," + centerPos.z(), error); + } finally { + level.setCurrentlyGenerating(null); + } + } + + private void placeStep(WorldGenLevel level, FeatureTable table, FeatureSorter.StepFeatureData stepData, + Registry featureRegistry, Set> chunkBiomes, + ChunkGenerator owner, WorldgenRandom random, long decorationSeed, + BlockPos origin, int stepIndex) { + IntSet stepFeatures = new IntArraySet(); + for (Holder biome : chunkBiomes) { + List> biomeFeatures = settingsFor(biome, table.derivatives()).features(); + if (stepIndex >= biomeFeatures.size()) { + continue; + } + for (Holder feature : biomeFeatures.get(stepIndex)) { + stepFeatures.add(stepData.indexMapping().applyAsInt(feature.value())); + } + } + if (stepFeatures.isEmpty()) { + return; + } + // Sorted global indices: identical ordering to vanilla, and each feature's seed comes from its own + // global index, so denying one feature never shifts another. + int[] featureIndices = stepFeatures.toIntArray(); + Arrays.sort(featureIndices); + for (int globalIndex : featureIndices) { + PlacedFeature feature = stepData.features().get(globalIndex); + if (table.filtered()) { + Identifier featureId = featureRegistry.getKey(feature); + if (featureId != null && !table.control().shouldGenerate(featureId.toString())) { + continue; + } + } + random.setFeatureSeed(decorationSeed, globalIndex, stepIndex); + level.setCurrentlyGenerating(() -> describeFeature(featureRegistry, feature)); + feature.placeWithBiomeCheck(level, owner, random, origin); + } + } + + private static String describeFeature(Registry registry, PlacedFeature feature) { + Identifier id = registry.getKey(feature); + return id == null ? feature.toString() : id.toString(); + } + + private Set> chunkBiomes(WorldGenLevel level, SectionPos sectionPos, FeatureTable table) { + List> collected = new ArrayList<>(); + ChunkPos.rangeClosed(sectionPos.chunk(), 1).forEach((ChunkPos chunkPos) -> { + ChunkAccess neighbour = level.getChunk(chunkPos.x(), chunkPos.z()); + for (LevelChunkSection section : neighbour.getSections()) { + section.getBiomes().getAll(collected::add); + } + }); + Set> present = new LinkedHashSet<>(); + for (Holder biome : collected) { + if (table.biomeSet().contains(biome)) { + present.add(biome); + continue; + } + String key = holderKey(biome); + Holder canonical = key == null ? null : table.byKey().get(key); + if (canonical != null) { + present.add(canonical); + } + } + return present; + } + + private static void addKey(Set keys, String key) { + String normalized = normalizeKey(key); + if (normalized != null) { + keys.add(normalized); + } + } + + private String dimensionKey() { + IrisDimension dimension = engine.getDimension(); + return dimension == null ? "" : dimension.getLoadKey(); + } + + private static String holderKey(Holder holder) { + return holder.unwrapKey() + .map(key -> key.identifier().toString().toLowerCase(Locale.ROOT)) + .orElse(null); + } + + private static String normalizeKey(String key) { + Identifier identifier = key == null ? null : Identifier.tryParse(key); + return identifier == null ? null : identifier.toString().toLowerCase(Locale.ROOT); + } + + private record FeatureTable(IrisDimension dimension, IrisImportedFeatureControl control, + List> biomes, Set> biomeSet, + Map> byKey, + List steps, + Map> derivatives, boolean filtered) { + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index ace3cad0f..e52cb14c3 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -50,6 +50,7 @@ import net.minecraft.world.level.NoiseColumn; import net.minecraft.world.level.StructureManager; import net.minecraft.world.level.WorldGenLevel; import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeGenerationSettings; import net.minecraft.world.level.biome.BiomeManager; import net.minecraft.world.level.biome.BiomeSource; import net.minecraft.world.level.biome.MobSpawnSettings; @@ -106,6 +107,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { private final int runtimeHeight; private final int runtimeSeaLevel; private final ConcurrentHashMap> mergedSpawnTables = new ConcurrentHashMap<>(); + private final ImportedFeatureStage importedFeatures; private volatile ReachableStructureCache reachableStructureCache; private volatile StructureStepCache structureStepCache; @@ -118,6 +120,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { this.delegate = delegate; this.engine = engine; this.customBiomeSource = customBiomeSource; + this.importedFeatures = new ImportedFeatureStage(engine); ServerLevel level = ((CraftWorld) world).getHandle(); this.runtimeMinY = level.getMinY(); this.runtimeHeight = level.getHeight(); @@ -425,14 +428,33 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public void applyBiomeDecoration(WorldGenLevel generatoraccessseed, ChunkAccess ichunkaccess, StructureManager structuremanager, boolean vanilla) { + // Bind-time equivalent for Bukkit: the table is built on the first decorated chunk, which is where a + // feature-order cycle is reported once and degraded to features-off. + importedFeatures.prepare(generatoraccessseed); try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_biome_decoration"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { addVanillaDecorations(generatoraccessseed, ichunkaccess, structuremanager); placeVanillaStructures(generatoraccessseed, ichunkaccess, structuremanager); + // Vanilla's placed-feature pass, on THIS thread. The delegate is still called with + // addVanillaDecorations=false below, so the vanilla half never runs twice. Inert unless the + // dimension set importedFeatures.enabled. + importedFeatures.run(generatoraccessseed, ichunkaccess, this); delegate.applyBiomeDecoration(generatoraccessseed, ichunkaccess, structuremanager, false); } } + /** + * Iris custom biomes carry no features in their datapack JSON by design; when importedFeatures is on they + * inherit the generation settings of the vanilla biome their Iris biome derives from. This is the gate + * {@code BiomeFilter} consults, so it has to agree with the feature pass. With the control off this is + * exactly the inherited behaviour. + */ + @Override + public BiomeGenerationSettings getBiomeGenerationSettings(Holder holder) { + BiomeGenerationSettings imported = importedFeatures.generationSettings(holder); + return imported == null ? super.getBiomeGenerationSettings(holder) : imported; + } + private void placeVanillaStructures(WorldGenLevel world, ChunkAccess chunk, StructureManager structureManager) { if (!structureManager.shouldGenerateStructures()) { ChunkPos disabledChunk = chunk.getPos(); diff --git a/adapters/bukkit/plugin/build.gradle b/adapters/bukkit/plugin/build.gradle index 749b176da..5a40bce83 100644 --- a/adapters/bukkit/plugin/build.gradle +++ b/adapters/bukkit/plugin/build.gradle @@ -42,6 +42,15 @@ tasks.named('processResources').configure { } } +tasks.named('compileJava', JavaCompile).configure { + // lombok.config decides how equals/hashCode/toString are generated (fields, never getters), so editing it + // changes this module's bytecode. Gradle does not know that on its own and would serve a stale up-to-date + // build until something else in the module changed. + inputs.file(rootProject.file('lombok.config')) + .withPropertyName('lombokConfig') + .withPathSensitivity(PathSensitivity.NONE) +} + tasks.named('jar', Jar).configure { archiveBaseName.set('iris-bukkit-plugin') } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java index 40b7172f7..1527ed6ef 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/CommandSVC.java @@ -231,11 +231,12 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D return false; } - DirectorMiniMenu.deliver(sender, DirectorMiniMenu.render( + DirectorMiniMenu.deliver( + sender, request.get(), DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver() - )); + ); return true; } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisProtocolService.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisProtocolService.java index ab56f9341..15e0069ed 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisProtocolService.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisProtocolService.java @@ -52,6 +52,7 @@ public class IrisProtocolService implements IrisService, PluginMessageListener, private IrisSessionRegistry registry; private IrisProtocolServer protocolServer; + private IrisVisionRequestService visionService; @Override public void onEnable() { @@ -62,7 +63,8 @@ public class IrisProtocolService implements IrisService, PluginMessageListener, protocolServer = new IrisProtocolServer(registry, SERVER_CAPABILITIES, brand(), true); EngineResolver engineResolver = IrisProtocolService::resolveEngine; protocolServer.setEngineResolver(engineResolver); - protocolServer.setVisionTileHandler(IrisVisionRequestService.create(engineResolver, registry)); + visionService = IrisVisionRequestService.create(engineResolver, registry); + protocolServer.setVisionTileHandler(visionService); IrisServices.register(IrisProtocolServer.class, protocolServer); for (Player player : Bukkit.getOnlinePlayers()) { registry.register(new IrisSession(player.getUniqueId().toString(), this)); @@ -83,6 +85,7 @@ public class IrisProtocolService implements IrisService, PluginMessageListener, } registry = null; protocolServer = null; + visionService = null; } @EventHandler @@ -109,7 +112,12 @@ public class IrisProtocolService implements IrisService, PluginMessageListener, if (current == null) { return; } - current.unregister(event.getPlayer().getUniqueId().toString()); + String sessionId = event.getPlayer().getUniqueId().toString(); + current.unregister(sessionId); + IrisVisionRequestService vision = visionService; + if (vision != null) { + vision.clearSession(sessionId); + } } @Override diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientHud.java b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientHud.java index 3832c888b..1df3a5c30 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientHud.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientHud.java @@ -2,12 +2,26 @@ package art.arcane.iris.client; import net.minecraft.client.gui.GuiGraphicsExtractor; +/** + * CLIENT DIST ONLY. References net.minecraft.client and must never be reachable from + * art.arcane.iris.modded or art.arcane.iris.nativegen. ModdedClientPackageIsolationTest enforces that + * direction; there is no @Environment annotation because net.fabricmc.api is not on the Forge or NeoForge + * compile classpath and this source set builds for all three loaders. + */ public final class IrisClientHud { private IrisClientHud() { } - public static void render(GuiGraphicsExtractor graphics) { + /** + * Per client tick, independent of the HUD layer. Toasts are pumped here rather than from + * {@link #render(GuiGraphicsExtractor)} because the whole layered HUD draw is skipped while hideGui (F1) + * is on, which would silently strand every queued toast until the player pressed F1 again. + */ + public static void tick() { IrisToastPresenter.pump(); + } + + public static void render(GuiGraphicsExtractor graphics) { IrisPregenHud.render(graphics); IrisWhatOverlay.render(graphics); } diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientKeybinds.java b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientKeybinds.java index 5ba3b0f71..e50cfbe09 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientKeybinds.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientKeybinds.java @@ -4,6 +4,12 @@ import net.minecraft.client.KeyMapping; import net.minecraft.client.Minecraft; import org.lwjgl.glfw.GLFW; +/** + * CLIENT DIST ONLY. Static KeyMapping fields plus LWJGL constants; loading this on a dedicated server is a + * NoClassDefFoundError. Reachable only from the per-loader client shims, which are Dist.CLIENT gated. + * ModdedClientPackageIsolationTest enforces that no modded or nativegen class reaches it. No @Environment + * annotation: net.fabricmc.api is absent from the Forge and NeoForge compile classpath. + */ public final class IrisClientKeybinds { private static final KeyMapping.Category CATEGORY = KeyMapping.Category.register(IrisClient.KEYBIND_CATEGORY_ID); public static final KeyMapping TOGGLE_HUD = new KeyMapping(IrisClient.KEYBIND_TOGGLE_HUD, GLFW.GLFW_KEY_H, CATEGORY); diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientMarkers.java b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientMarkers.java index 33de3d60f..6582cbbaf 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientMarkers.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientMarkers.java @@ -2,26 +2,41 @@ package art.arcane.iris.client; import art.arcane.iris.spi.protocol.IrisMessage; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +/** + * Marker overlays keyed by tile, straight off the wire. Access-ordered and capped: the server decides how many + * distinct tiles it sends markers for, so an unbounded map here is a remote memory dial. + */ public final class IrisClientMarkers { - private final Map> byTile; + static final int MAX_TILES = 64; + + private final LinkedHashMap> byTile; public IrisClientMarkers() { - this.byTile = new ConcurrentHashMap<>(); + this.byTile = new LinkedHashMap<>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry> eldest) { + return size() > MAX_TILES; + } + }; } - public void onMarkers(IrisMessage.VisionMarkers markers) { + public synchronized void onMarkers(IrisMessage.VisionMarkers markers) { byTile.put(new IrisTileKey(markers.tileX(), markers.tileZ(), markers.zoomLevel()), List.copyOf(markers.markers())); } - public List forTile(IrisTileKey key) { + public synchronized List forTile(IrisTileKey key) { return byTile.get(key); } - public void clear() { + public synchronized int trackedTiles() { + return byTile.size(); + } + + public synchronized void clear() { byTile.clear(); } } diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientPregenState.java b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientPregenState.java index ca6267552..038591ccb 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientPregenState.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientPregenState.java @@ -2,23 +2,47 @@ package art.arcane.iris.client; import art.arcane.iris.spi.protocol.IrisMessage; -import java.util.concurrent.ConcurrentHashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.LongSupplier; +/** + * Pregeneration progress as last seen on the wire. Capped by job count, and each entry carries the receive + * time so the HUD can tell a live job from a server that stopped sending - progress frames only arrive while a + * job ticks, so a stalled or crashed job otherwise leaves a frozen bar on screen forever. + */ public final class IrisClientPregenState { - private final ConcurrentHashMap jobs; - private volatile Long activeJobId; + /** Beyond this the panel reads as unreliable and the HUD mutes it. */ + public static final long STALE_AFTER_MILLIS = 5_000L; + /** Beyond this the HUD stops drawing the panel at all. */ + public static final long EXPIRE_AFTER_MILLIS = 30_000L; + static final int MAX_JOBS = 32; + + private final LongSupplier clock; + private final LinkedHashMap jobs; + private Long activeJobId; public IrisClientPregenState() { - this.jobs = new ConcurrentHashMap<>(); + this(System::currentTimeMillis); + } + + IrisClientPregenState(LongSupplier clock) { + this.clock = clock; + this.jobs = new LinkedHashMap<>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_JOBS; + } + }; this.activeJobId = null; } - public void onProgress(IrisMessage.PregenProgress progress) { - jobs.put(progress.jobId(), progress); + public synchronized void onProgress(IrisMessage.PregenProgress progress) { + jobs.put(progress.jobId(), new Entry(progress, clock.getAsLong())); activeJobId = progress.jobId(); } - public void onEnd(long jobId) { + public synchronized void onEnd(long jobId) { jobs.remove(jobId); Long current = activeJobId; if (current != null && current == jobId) { @@ -26,7 +50,41 @@ public final class IrisClientPregenState { } } - public IrisMessage.PregenProgress active() { + public synchronized IrisMessage.PregenProgress active() { + Entry entry = activeEntry(); + return entry == null ? null : entry.progress(); + } + + /** Millis since the active job last reported, or -1 when there is no active job. */ + public synchronized long activeAgeMillis() { + Entry entry = activeEntry(); + return entry == null ? -1L : Math.max(0L, clock.getAsLong() - entry.receivedAtMillis()); + } + + public synchronized boolean activeStale() { + long age = activeAgeMillis(); + return age >= STALE_AFTER_MILLIS; + } + + public synchronized boolean activeExpired() { + long age = activeAgeMillis(); + return age >= EXPIRE_AFTER_MILLIS; + } + + public synchronized Long activeJobId() { + return activeJobId; + } + + public synchronized int trackedJobs() { + return jobs.size(); + } + + public synchronized void clear() { + jobs.clear(); + activeJobId = null; + } + + private Entry activeEntry() { Long current = activeJobId; if (current == null) { return null; @@ -34,12 +92,6 @@ public final class IrisClientPregenState { return jobs.get(current); } - public Long activeJobId() { - return activeJobId; - } - - public void clear() { - jobs.clear(); - activeJobId = null; + private record Entry(IrisMessage.PregenProgress progress, long receivedAtMillis) { } } diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientSession.java b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientSession.java index 6745b308f..296529685 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientSession.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientSession.java @@ -14,6 +14,7 @@ public final class IrisClientSession { private final LongSupplier clock; private volatile State state; private volatile long serverCapabilities; + private volatile int serverProtocolVersion; private volatile boolean irisActive; private volatile String serverBrand; private volatile ClientPacketSink sink; @@ -28,6 +29,7 @@ public final class IrisClientSession { this.clock = clock; this.state = State.IDLE; this.serverCapabilities = 0L; + this.serverProtocolVersion = 0; this.irisActive = false; this.serverBrand = ""; this.sink = null; @@ -55,6 +57,11 @@ public final class IrisClientSession { return serverCapabilities; } + /** The version the server reported, retained even on a mismatch so the UI can name it. 0 before any reply. */ + public int serverProtocolVersion() { + return serverProtocolVersion; + } + public String serverBrand() { return serverBrand; } @@ -79,6 +86,11 @@ public final class IrisClientSession { private void sendHelloAttempt() { ClientPacketSink activeSink = sink; if (activeSink == null) { + // No sink yet means the loader has not bound the channel. Still burn an attempt and arm the retry + // clock, otherwise nextHelloAt stays MAX_VALUE, tick() never fires again and the UI sits on + // "connecting" for the whole session. + helloAttempts++; + nextHelloAt = clock.getAsLong() + HELLO_RETRY_MILLIS; return; } byte[] frame = IrisMessageCodec.encode(new IrisMessage.ClientHello(IrisProtocol.PROTOCOL_VERSION, CLIENT_CAPABILITIES)); @@ -89,7 +101,9 @@ public final class IrisClientSession { } public void onServerHello(IrisMessage.ServerHello hello) { + this.serverProtocolVersion = hello.protocolVersion(); if (hello.protocolVersion() != IrisProtocol.PROTOCOL_VERSION) { + this.serverBrand = hello.serverBrand(); this.state = State.INCOMPATIBLE; return; } @@ -102,6 +116,7 @@ public final class IrisClientSession { public void reset() { this.state = State.IDLE; this.serverCapabilities = 0L; + this.serverProtocolVersion = 0; this.irisActive = false; this.serverBrand = ""; this.nextHelloAt = Long.MAX_VALUE; diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientTileCache.java b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientTileCache.java index 5b54d7cc4..1918211cc 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientTileCache.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisClientTileCache.java @@ -3,6 +3,7 @@ package art.arcane.iris.client; 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 java.util.ArrayDeque; import java.util.Deque; @@ -14,7 +15,10 @@ import java.util.Set; import java.util.function.LongSupplier; public final class IrisClientTileCache { - private static final int MAX_CACHED_TILES = 256; + /** Enough for a 1080p viewport plus a ring of prefetch. 128x128 ARGB tiles are 64KB each. */ + static final int DEFAULT_MAX_CACHED_TILES = 256; + /** Ceiling for {@link #ensureCapacity(int)}: 4K at scale 1 needs ~660 tiles, so ~64MB of tile images. */ + static final int ABSOLUTE_MAX_CACHED_TILES = 2048; private static final long REQUEST_RETRY_MILLIS = 3000L; private static final int REQUESTS_PER_SECOND = IrisProtocol.MAX_VISION_TILE_REQUESTS_PER_SECOND; @@ -25,17 +29,20 @@ public final class IrisClientTileCache { private final Map pending; private final Deque queue; private final Set queued; + private int capacity; private long windowStartMillis; private int sentInWindow; + private long droppedMalformed; public IrisClientTileCache(ClientPacketSink sink, LongSupplier clock) { this.sink = sink; this.clock = clock; this.assembler = new IrisTileAssembler(); + this.capacity = DEFAULT_MAX_CACHED_TILES; this.cache = new LinkedHashMap<>(64, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_CACHED_TILES; + return size() > capacity; } }; this.pending = new HashMap<>(); @@ -43,10 +50,25 @@ public final class IrisClientTileCache { this.queued = new HashSet<>(); this.windowStartMillis = 0L; this.sentInWindow = 0; + this.droppedMalformed = 0L; + } + + /** + * Raises the retained-tile budget to cover what the viewport can show at once. Without this a 4K screen at + * scale 1 evicts tiles it is still drawing, so every frame re-requests them and the map never fills. + */ + public synchronized void ensureCapacity(int visibleTiles) { + capacity = Math.max(DEFAULT_MAX_CACHED_TILES, Math.min(ABSOLUTE_MAX_CACHED_TILES, visibleTiles)); } public synchronized void onVisionTile(IrisMessage.VisionTile tile) { - IrisTileImage image = assembler.add(tile); + IrisTileImage image; + try { + image = assembler.add(tile); + } catch (ProtocolException malformed) { + droppedMalformed++; + return; + } if (image == null) { return; } @@ -60,6 +82,10 @@ public final class IrisClientTileCache { return cache.get(key); } + public synchronized long droppedMalformedCount() { + return droppedMalformed; + } + public synchronized void resetRequestQueue() { queue.clear(); queued.clear(); @@ -84,6 +110,9 @@ public final class IrisClientTileCache { if (now - windowStartMillis >= 1000L) { windowStartMillis = now; sentInWindow = 0; + // An in-flight marker is only meaningful for one retry window. Sweeping it here keeps the map + // bounded by tiles requested in the last few seconds instead of by every tile ever panned over. + pending.values().removeIf((Long requestedAt) -> now - requestedAt >= REQUEST_RETRY_MILLIS); } while (sentInWindow < REQUESTS_PER_SECOND && !queue.isEmpty()) { IrisTileKey key = queue.pollFirst(); @@ -107,6 +136,7 @@ public final class IrisClientTileCache { pending.clear(); queue.clear(); queued.clear(); + capacity = DEFAULT_MAX_CACHED_TILES; sentInWindow = 0; windowStartMillis = 0L; } diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisPregenHud.java b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisPregenHud.java index d3406a06e..8dfa599a5 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisPregenHud.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisPregenHud.java @@ -9,6 +9,10 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphicsExtractor; +/** + * CLIENT DIST ONLY. See {@link IrisClientHud} for why the dist marker is a javadoc contract plus a bytecode + * test rather than an @Environment annotation. + */ public final class IrisPregenHud { private static final int PANEL_COLOR = 0xC0101010; private static final int TITLE_COLOR = 0xFF66BB6A; @@ -17,6 +21,7 @@ public final class IrisPregenHud { private static final int BAR_BACK_COLOR = 0xFF2B2B2B; private static final int BAR_RUNNING_COLOR = 0xFF66BB6A; private static final int PAUSED_COLOR = 0xFFFFD54F; + private static final int STALE_COLOR = 0xFF8A8A8A; private static final int GRID_BACK_COLOR = 0xFF161616; private static final int CELL_PENDING_COLOR = 0xFF3A3A3A; private static final int CELL_GENERATING_COLOR = 0xFFFFD54F; @@ -38,8 +43,9 @@ public final class IrisPregenHud { if (!IrisClient.hudVisible()) { return; } - IrisMessage.PregenProgress progress = IrisClient.pregen().active(); - if (progress == null) { + IrisClientPregenState pregen = IrisClient.pregen(); + IrisMessage.PregenProgress progress = pregen.active(); + if (progress == null || pregen.activeExpired()) { return; } Minecraft minecraft = Minecraft.getInstance(); @@ -47,6 +53,7 @@ public final class IrisPregenHud { return; } Font font = minecraft.font; + boolean stale = pregen.activeStale(); boolean paused = progress.state() == IrisMessage.PregenProgress.STATE_PAUSED; double percent = progress.chunksTotal() > 0L ? clampPercent((double) progress.chunksDone() / (double) progress.chunksTotal() * 100.0D) @@ -58,8 +65,8 @@ public final class IrisPregenHud { MessageArgument.trusted("total", String.format("%,d", progress.chunksTotal())), MessageArgument.trusted("percent", String.format("%.1f", percent)) ); - String tail = paused ? IrisLanguage.plain(ClientUiMessages.PREGEN_PAUSED) : rateAndEta(progress); - int accent = paused ? PAUSED_COLOR : BAR_RUNNING_COLOR; + String tail = tail(progress, pregen, stale, paused); + int accent = stale ? STALE_COLOR : paused ? PAUSED_COLOR : BAR_RUNNING_COLOR; int lineHeight = font.lineHeight; int contentWidth = Math.max(MIN_WIDTH, Math.max(font.width(title), Math.max(font.width(stats), font.width(tail)))); @@ -84,9 +91,9 @@ public final class IrisPregenHud { graphics.fill(ORIGIN_X - PADDING, ORIGIN_Y - PADDING, ORIGIN_X + panelWidth + PADDING, ORIGIN_Y + panelHeight + PADDING, PANEL_COLOR); int cursorY = ORIGIN_Y; - graphics.text(font, title, ORIGIN_X, cursorY, TITLE_COLOR); + graphics.text(font, title, ORIGIN_X, cursorY, stale ? STALE_COLOR : TITLE_COLOR); cursorY += lineHeight + ROW_GAP; - graphics.text(font, stats, ORIGIN_X, cursorY, TEXT_COLOR); + graphics.text(font, stats, ORIGIN_X, cursorY, stale ? STALE_COLOR : TEXT_COLOR); cursorY += lineHeight + ROW_GAP; int fillWidth = (int) Math.round(contentWidth * (percent / 100.0D)); @@ -96,7 +103,7 @@ public final class IrisPregenHud { } cursorY += BAR_HEIGHT + ROW_GAP; - graphics.text(font, tail, ORIGIN_X, cursorY, paused ? PAUSED_COLOR : MUTED_COLOR); + graphics.text(font, tail, ORIGIN_X, cursorY, stale ? STALE_COLOR : paused ? PAUSED_COLOR : MUTED_COLOR); if (showMap) { renderMinimap(graphics, regionMap, bounds, cellPx, gridWidth, gridHeight, ORIGIN_Y + contentHeight + MINIMAP_GAP); @@ -128,6 +135,15 @@ public final class IrisPregenHud { }; } + private static String tail(IrisMessage.PregenProgress progress, IrisClientPregenState pregen, boolean stale, boolean paused) { + if (stale) { + return IrisLanguage.plain( + ClientUiMessages.PREGEN_STALE, + MessageArgument.trusted("seconds", pregen.activeAgeMillis() / 1000L)); + } + return paused ? IrisLanguage.plain(ClientUiMessages.PREGEN_PAUSED) : rateAndEta(progress); + } + private static String rateAndEta(IrisMessage.PregenProgress progress) { String rate = String.format("%,.0f", progress.chunksPerSecond()); if (progress.etaMillis() > 0L) { diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisTileAssembler.java b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisTileAssembler.java index e50cf361b..175843eea 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisTileAssembler.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisTileAssembler.java @@ -2,15 +2,18 @@ package art.arcane.iris.client; import art.arcane.iris.spi.protocol.IrisMessage; import art.arcane.iris.spi.protocol.IrisProtocol; +import art.arcane.iris.spi.protocol.ProtocolException; import java.util.LinkedHashMap; import java.util.Map; final class IrisTileAssembler { - private static final int MAX_CHUNK_COUNT = + /** More chunks than the largest legal tile can occupy means the header is lying. */ + static final int MAX_CHUNK_COUNT = (IrisTileCodec.MAX_DECODED_BYTES + IrisProtocol.VISION_TILE_MAX_CHUNK_BYTES - 1) / IrisProtocol.VISION_TILE_MAX_CHUNK_BYTES + 1; - private static final int MAX_PENDING_TILES = 64; + /** Half-assembled tiles retained at once; the oldest is dropped past this. */ + static final int MAX_PENDING_TILES = 64; private final Map partials; @@ -23,7 +26,13 @@ final class IrisTileAssembler { }; } - IrisTileImage add(IrisMessage.VisionTile tile) { + /** + * @return the finished image once the last chunk of a set lands, or null while the set is incomplete or the + * frame is structurally unusable (impossible index or count, missing payload, a chunk from a set + * older than the one being assembled) + * @throws ProtocolException when a complete set decodes to a malformed blob + */ + IrisTileImage add(IrisMessage.VisionTile tile) throws ProtocolException { int chunkCount = tile.chunkCount(); int chunkIndex = tile.chunkIndex(); if (chunkCount <= 0 || chunkCount > MAX_CHUNK_COUNT || chunkIndex < 0 || chunkIndex >= chunkCount || tile.data() == null) { @@ -31,14 +40,12 @@ final class IrisTileAssembler { } IrisTileKey key = new IrisTileKey(tile.tileX(), tile.tileZ(), tile.zoomLevel()); Partial partial = partials.get(key); + if (partial != null && tile.sequence() < partial.sequence()) { + return null; + } if (partial == null || tile.sequence() > partial.sequence() || partial.chunkCount() != chunkCount) { - if (partial != null && tile.sequence() < partial.sequence()) { - return null; - } partial = new Partial(tile.sequence(), chunkCount); partials.put(key, partial); - } else if (tile.sequence() < partial.sequence()) { - return null; } if (!partial.accept(chunkIndex, tile.data())) { return null; diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisTileCodec.java b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisTileCodec.java index b325db4a5..a4a501bec 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisTileCodec.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisTileCodec.java @@ -1,8 +1,11 @@ package art.arcane.iris.client; +import art.arcane.iris.spi.protocol.ProtocolException; + import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; +import java.io.EOFException; import java.io.IOException; import java.util.zip.DataFormatException; import java.util.zip.Inflater; @@ -17,36 +20,43 @@ public final class IrisTileCodec { private IrisTileCodec() { } - public static IrisTileImage decode(byte[] deflatedBlob) { + /** + * Decodes an assembled tile blob. + * + * @return null when {@code deflatedBlob} is null or empty - nothing to decode is not an error + * @throws ProtocolException when the blob is present but malformed: a corrupt or stalled deflate stream, an + * oversized payload, bad dimensions, an unknown pixel mode, a bad palette or a + * truncated pixel run. Callers drop the tile and count it; the wire is untrusted. + */ + public static IrisTileImage decode(byte[] deflatedBlob) throws ProtocolException { if (deflatedBlob == null || deflatedBlob.length == 0) { return null; } byte[] raw = inflate(deflatedBlob); - if (raw == null) { - return null; - } try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(raw))) { int width = in.readInt(); int height = in.readInt(); if (width <= 0 || height <= 0 || width > MAX_DIMENSION || height > MAX_DIMENSION) { - return null; + throw new ProtocolException("vision tile dimensions " + width + "x" + height + " out of range"); } int mode = in.readUnsignedByte(); int[] argb = new int[width * height]; return switch (mode) { case MODE_PALETTE -> decodePalette(in, width, height, argb); case MODE_RAW_RGB -> decodeRaw(in, width, height, argb); - default -> null; + default -> throw new ProtocolException("unknown vision tile mode " + mode); }; + } catch (EOFException truncated) { + throw new ProtocolException("vision tile blob truncated"); } catch (IOException failure) { - return null; + throw new ProtocolException("vision tile blob unreadable: " + failure.getMessage()); } } - private static IrisTileImage decodePalette(DataInputStream in, int width, int height, int[] argb) throws IOException { + private static IrisTileImage decodePalette(DataInputStream in, int width, int height, int[] argb) throws IOException, ProtocolException { int paletteSize = in.readInt(); if (paletteSize <= 0 || paletteSize > 256) { - return null; + throw new ProtocolException("vision tile palette size " + paletteSize + " out of range"); } int[] palette = new int[paletteSize]; for (int index = 0; index < paletteSize; index++) { @@ -58,7 +68,7 @@ public final class IrisTileCodec { for (int pixel = 0; pixel < argb.length; pixel++) { int paletteIndex = in.readUnsignedByte(); if (paletteIndex >= paletteSize) { - return null; + throw new ProtocolException("vision tile palette index " + paletteIndex + " beyond size " + paletteSize); } argb[pixel] = palette[paletteIndex]; } @@ -75,7 +85,7 @@ public final class IrisTileCodec { return new IrisTileImage(width, height, argb); } - private static byte[] inflate(byte[] input) { + private static byte[] inflate(byte[] input) throws ProtocolException { Inflater inflater = new Inflater(); inflater.setInput(input); ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(64, input.length * 2)); @@ -84,17 +94,23 @@ public final class IrisTileCodec { while (!inflater.finished()) { int produced = inflater.inflate(buffer); if (produced == 0) { - if (inflater.finished() || inflater.needsInput() || inflater.needsDictionary()) { + if (inflater.finished()) { break; } + // needsInput here means the stream ended mid-member; needsDictionary means it wants a + // preset dictionary the encoder never uses. Either way there is no more progress to make, + // and looping on a zero-progress inflater spins a render thread forever. + throw new ProtocolException(inflater.needsDictionary() + ? "vision tile stream wants a preset dictionary" + : "vision tile stream ended before the deflate member finished"); } if (out.size() + produced > MAX_DECODED_BYTES) { - return null; + throw new ProtocolException("vision tile inflates beyond " + MAX_DECODED_BYTES + " bytes"); } out.write(buffer, 0, produced); } } catch (DataFormatException malformed) { - return null; + throw new ProtocolException("vision tile stream malformed: " + malformed.getMessage()); } finally { inflater.end(); } diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisToastPresenter.java b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisToastPresenter.java index 36b63709e..a806084a8 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisToastPresenter.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisToastPresenter.java @@ -6,6 +6,10 @@ import net.minecraft.client.gui.components.toasts.SystemToast; import net.minecraft.client.gui.components.toasts.ToastManager; import net.minecraft.network.chat.Component; +/** + * CLIENT DIST ONLY. See {@link IrisClientHud} for why the dist marker is a javadoc contract plus a bytecode + * test rather than an @Environment annotation. + */ public final class IrisToastPresenter { private IrisToastPresenter() { } diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisVisionScreen.java b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisVisionScreen.java index f252d937a..4ca86a448 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisVisionScreen.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisVisionScreen.java @@ -22,12 +22,21 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +/** + * CLIENT DIST ONLY. See {@link IrisClientHud} for why the dist marker is a javadoc contract plus a bytecode + * test rather than an @Environment annotation. + */ public final class IrisVisionScreen extends Screen { private static final int TILE_PIXELS = 128; private static final int MIN_ZOOM = 0; private static final int MAX_ZOOM = 8; private static final int DEFAULT_ZOOM = 2; - private static final int MAX_TEXTURES = 220; + /** Floor for the GPU texture budget, so a small window still keeps a prefetch ring resident. */ + private static final int MIN_TEXTURES = 220; + /** Ceiling, matching the tile cache: 4K at scale 1 draws ~660 tiles at once. */ + private static final int MAX_TEXTURES = IrisClientTileCache.ABSOLUTE_MAX_CACHED_TILES; + /** Extra rings of tiles kept resident beyond what is on screen right now. */ + private static final int TEXTURE_SLACK_RINGS = 1; private static final int BACKGROUND_COLOR = 0xF00B0E14; private static final int PLACEHOLDER_COLOR = 0xFF161A22; private static final int GRID_COLOR = 0x33FFFFFF; @@ -75,7 +84,7 @@ public final class IrisVisionScreen extends Screen { graphics.fill(0, 0, width, height, BACKGROUND_COLOR); IrisClientSession session = IrisClient.session(); if (!session.isReady()) { - drawCentered(graphics, IrisLanguage.plain(ClientUiMessages.VISION_CONNECTING), MUTED_COLOR); + drawCentered(graphics, handshakeMessage(session), MUTED_COLOR); drawHeader(graphics, IrisLanguage.plain(ClientUiMessages.VISION_TITLE), IrisLanguage.plain(ClientUiMessages.VISION_NOT_CONNECTED)); return; } @@ -91,7 +100,9 @@ public final class IrisVisionScreen extends Screen { @Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { - return true; + // The map itself has nothing clickable, but swallowing every click also swallows the ones widgets and + // the parent screen need. Let Screen route it; drag and scroll are handled below. + return super.mouseClicked(event, doubleClick); } @Override @@ -102,6 +113,11 @@ public final class IrisVisionScreen extends Screen { return true; } + /** + * Known limitation, not a bug to chase: a zoom change makes every cached tile the wrong scale, because the + * zoom level is part of the tile key. The new level's tiles are re-requested at 8/s, so the map repaints + * from placeholders. Rendering the old level scaled would need a second texture path for a transient view. + */ @Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { if (scrollY == 0.0D) { @@ -136,10 +152,16 @@ public final class IrisVisionScreen extends Screen { int centerTileX = Math.floorDiv((int) Math.floor(centerBlockX), tileSpanBlocks); int centerTileZ = Math.floorDiv((int) Math.floor(centerBlockZ), tileSpanBlocks); + int visibleTiles = (maxTileX - minTileX + 1) * (maxTileZ - minTileZ + 1); IrisClientTileCache cache = IrisClient.tiles(); + cache.ensureCapacity(visibleTiles); cache.resetRequestQueue(); List missing = new ArrayList<>(); + // Evict before the blit loop, never after. A DynamicTexture released mid-frame is a closed GPU handle + // that the already-recorded blit still points at, which is a use-after-close on the render thread. + evictTextures(textureCapacity(visibleTiles)); + for (int tileX = minTileX; tileX <= maxTileX; tileX++) { for (int tileZ = minTileZ; tileZ <= maxTileZ; tileZ++) { int screenX = originX + tileX * TILE_PIXELS; @@ -158,7 +180,6 @@ public final class IrisVisionScreen extends Screen { requestMissing(cache, missing, centerTileX, centerTileZ); cache.pump(); - evictTextures(); drawMarkers(graphics, mouseX, mouseY, minTileX, maxTileX, minTileZ, maxTileZ, originX, originY, blocksPerPixel); drawPlayer(graphics, blocksPerPixel); @@ -246,7 +267,14 @@ public final class IrisVisionScreen extends Screen { private Identifier ensureTexture(IrisTileKey key, IrisTileImage image) { TileTexture existing = textures.get(key); if (existing != null) { - return existing.id(); + if (existing.image() == image) { + return existing.id(); + } + // IrisClientTileCache replaced the tile with a freshly decoded image (new sequence from the + // server). Identity is the generation counter: a re-decode is always a new record instance, so an + // upload keyed only on the tile coordinates would show the stale render forever. + Minecraft.getInstance().getTextureManager().release(existing.id()); + textures.remove(key); } int tileWidth = image.width(); int tileHeight = image.height(); @@ -261,17 +289,26 @@ public final class IrisVisionScreen extends Screen { DynamicTexture texture = new DynamicTexture(() -> "iris_vision_tile", nativeImage); Identifier id = Identifier.fromNamespaceAndPath("irisworldgen", texturePath(key)); Minecraft.getInstance().getTextureManager().register(id, texture); - textures.put(key, new TileTexture(id, texture)); + textures.put(key, new TileTexture(id, image)); return id; } - private void evictTextures() { - if (textures.size() <= MAX_TEXTURES) { + /** + * Texture budget for the current viewport: everything on screen plus a slack ring, clamped to sane bounds. + * A fixed 220 is under half of what 4K at scale 1 draws, so the eviction pass used to fight the blit loop. + */ + private static int textureCapacity(int visibleTiles) { + long required = (long) visibleTiles * (1 + 2 * TEXTURE_SLACK_RINGS); + return (int) Math.max(MIN_TEXTURES, Math.min(MAX_TEXTURES, required)); + } + + private void evictTextures(int capacity) { + if (textures.size() <= capacity) { return; } TextureManager manager = Minecraft.getInstance().getTextureManager(); Iterator> iterator = textures.entrySet().iterator(); - while (textures.size() > MAX_TEXTURES && iterator.hasNext()) { + while (textures.size() > capacity && iterator.hasNext()) { Map.Entry entry = iterator.next(); manager.release(entry.getValue().id()); iterator.remove(); @@ -309,6 +346,19 @@ public final class IrisVisionScreen extends Screen { } } + /** + * Distinct copy per terminal handshake state. UNSUPPORTED means the hello retries ran out with no answer - + * no Iris on the server. INCOMPATIBLE means Iris answered with a different wire version. Reporting either + * as "connecting" left the screen sitting on a spinner that would never resolve. + */ + private static String handshakeMessage(IrisClientSession session) { + return switch (session.state()) { + case UNSUPPORTED -> IrisLanguage.plain(ClientUiMessages.VISION_SERVER_WITHOUT_IRIS); + case INCOMPATIBLE -> IrisLanguage.plain(ClientUiMessages.VISION_VERSION_MISMATCH); + default -> IrisLanguage.plain(ClientUiMessages.VISION_CONNECTING); + }; + } + private static long distanceSquared(IrisTileKey key, int centerTileX, int centerTileZ) { long deltaX = (long) key.tileX() - centerTileX; long deltaZ = (long) key.tileZ() - centerTileZ; @@ -342,6 +392,11 @@ public final class IrisVisionScreen extends Screen { ); } - private record TileTexture(Identifier id, DynamicTexture texture) { + /** + * The registered texture id plus the exact image instance uploaded into it. The DynamicTexture itself is + * not retained: TextureManager owns it after register, and release(id) is the only handle needed. Holding + * the source image is what lets {@link #ensureTexture(IrisTileKey, IrisTileImage)} notice a re-decode. + */ + private record TileTexture(Identifier id, IrisTileImage image) { } } diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisWhatOverlay.java b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisWhatOverlay.java index 25c44b6c9..7c284fd21 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/IrisWhatOverlay.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/IrisWhatOverlay.java @@ -15,6 +15,10 @@ import net.minecraft.world.phys.HitResult; import java.util.ArrayList; import java.util.List; +/** + * CLIENT DIST ONLY. See {@link IrisClientHud} for why the dist marker is a javadoc contract plus a bytecode + * test rather than an @Environment annotation. + */ public final class IrisWhatOverlay { private static final int PANEL_COLOR = 0xC0101010; private static final int TITLE_COLOR = 0xFF66BB6A; diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/mixin/IrisWorldOpenFlowsMixin.java b/adapters/client-common/src/main/java/art/arcane/iris/client/mixin/IrisWorldOpenFlowsMixin.java index 7de17194f..fad50a286 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/mixin/IrisWorldOpenFlowsMixin.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/mixin/IrisWorldOpenFlowsMixin.java @@ -1,14 +1,20 @@ package art.arcane.iris.client.mixin; +import art.arcane.iris.core.localization.ClientUiMessages; +import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedMixinFlags; import com.mojang.serialization.Lifecycle; import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.AlertScreen; import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen; import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState; import net.minecraft.client.gui.screens.worldselection.WorldOpenFlows; import net.minecraft.core.Holder; import net.minecraft.core.Registry; import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceKey; import net.minecraft.server.WorldStem; import net.minecraft.server.packs.repository.PackRepository; @@ -23,6 +29,12 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import java.util.Optional; +/** + * CLIENT DIST ONLY. Registered from irisworldgen.client.mixins.json, whose "client" block already restricts + * application to the client dist. Target verified against MC 26.2: + * {@code WorldOpenFlows.confirmWorldCreation(Minecraft, CreateWorldScreen, Lifecycle, Runnable, boolean)} is + * static, and {@code openWorldCheckWorldStemCompatibility} is a private instance method. + */ @Mixin(WorldOpenFlows.class) public abstract class IrisWorldOpenFlowsMixin { @Invoker("openWorldLoadBundledResourcePack") @@ -40,13 +52,36 @@ public abstract class IrisWorldOpenFlowsMixin { Runnable task, boolean skipWarning, CallbackInfo info) { - if (skipWarning || lifecycle == Lifecycle.stable() || !iris$selectedPresetIsIris(parent)) { + ModdedMixinFlags.markWorldOpenFlows(); + if (!iris$selectedPresetIsIris(parent)) { + return; + } + if (!parent.getUiState().isGenerateStructures()) { + // Iris runs its own placement through the vanilla structure step, so a world created with + // Generate Structures off is refused at load by IrisModdedChunkGenerator. Stop it here, at the + // one screen that still has a toggle to fix, instead of at the load that would only report it. + iris$showStructuresRequired(minecraft, parent); + info.cancel(); + return; + } + if (skipWarning || lifecycle == Lifecycle.stable()) { return; } task.run(); info.cancel(); } + private static void iris$showStructuresRequired(Minecraft minecraft, CreateWorldScreen parent) { + // 5-arg ctor with shouldCloseOnEsc=false: ESC on the 3-arg ctor closes to the title screen, + // skipping CreateWorldScreen.onClose and leaking its temp datapack directory. + minecraft.gui.setScreen(new AlertScreen( + () -> minecraft.gui.setScreen(parent), + Component.literal(IrisLanguage.plain(ClientUiMessages.CREATE_STRUCTURES_REQUIRED_TITLE)), + Component.literal(IrisLanguage.plain(ClientUiMessages.CREATE_STRUCTURES_REQUIRED_BODY)), + CommonComponents.GUI_BACK, + false)); + } + @Inject(method = "openWorldCheckWorldStemCompatibility", at = @At("HEAD"), cancellable = true) private void iris$openWorldCheckWorldStemCompatibility( LevelStorageSource.LevelStorageAccess worldAccess, @@ -54,6 +89,7 @@ public abstract class IrisWorldOpenFlowsMixin { PackRepository packRepository, Runnable onCancel, CallbackInfo info) { + ModdedMixinFlags.markWorldOpenFlows(); if (!iris$containsIrisGenerator(worldStem)) { return; } diff --git a/adapters/client-common/src/main/java/art/arcane/iris/client/mixin/IrisWorldTypeEntryMixin.java b/adapters/client-common/src/main/java/art/arcane/iris/client/mixin/IrisWorldTypeEntryMixin.java index 95cafac67..b961cf949 100644 --- a/adapters/client-common/src/main/java/art/arcane/iris/client/mixin/IrisWorldTypeEntryMixin.java +++ b/adapters/client-common/src/main/java/art/arcane/iris/client/mixin/IrisWorldTypeEntryMixin.java @@ -1,5 +1,6 @@ package art.arcane.iris.client.mixin; +import art.arcane.iris.modded.ModdedMixinFlags; import art.arcane.iris.modded.ModdedWorldgenIds; import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState; import net.minecraft.core.Holder; @@ -15,6 +16,10 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; import java.util.Optional; +/** + * CLIENT DIST ONLY. Registered from irisworldgen.client.mixins.json, whose "client" block already restricts + * application to the client dist. + */ @Mixin(WorldCreationUiState.WorldTypeEntry.class) public class IrisWorldTypeEntryMixin { @Shadow @@ -23,6 +28,7 @@ public class IrisWorldTypeEntryMixin { @Inject(method = "describePreset", at = @At("HEAD"), cancellable = true) private void iris$describePreset(CallbackInfoReturnable info) { + ModdedMixinFlags.markWorldTypeEntry(); Optional> key = preset == null ? Optional.empty() : preset.unwrapKey(); diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index 54d865304..8cf5e181b 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -114,6 +114,14 @@ dependencies { minecraft("com.mojang:minecraft:${minecraftVersion}") implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}") testImplementation('junit:junit:4.13.2') + // registrySync and resourceLoader are LOAD-BEARING despite zero imports anywhere in Iris: + // fabric-registry-sync-v0 delays registry freeze past mod init, which is what lets + // IrisFabricBootstrap register the CHUNK_GENERATOR codec in + // onInitialize instead of crashing on a frozen registry. + // fabric-resource-loader-v1 is what makes assets/irisworldgen/lang/*.json (and the keybind + // labels) resolve from a mod jar. + // Removing either one produces a boot crash / silent missing-translation regression, not a + // compile error. Do not prune them as unused. List fabricApi = [ libs.fabricApi.base, libs.fabricApi.registrySync, @@ -180,6 +188,7 @@ loom { vmArg('-Xmx8G') } server { + runDir(providers.gradleProperty('irisServerRunDir').getOrElse('run')) String parity = providers.gradleProperty('irisParity').getOrNull() if (parity != null) { property('iris.parity', parity) @@ -210,10 +219,28 @@ processResources { } } +// META-INF/jars filenames are declared verbatim in the fabric.mod.json `jars[]` array, and Fabric +// loader resolves them by that exact string. Map every bundled module to its declared filename +// instead of stripping the version with a regex: a version containing a '-' silently defeats the +// strip and leaves an undeclared nested jar (which loader then ignores) behind. +Map nestedFabricApiJars = [ + 'fabric-api-base' : 'fabric-api-base.jar', + 'fabric-registry-sync-v0' : 'fabric-registry-sync-v0.jar', + 'fabric-resource-loader-v1' : 'fabric-resource-loader-v1.jar', + 'fabric-lifecycle-events-v1' : 'fabric-lifecycle-events-v1.jar', + 'fabric-command-api-v2' : 'fabric-command-api-v2.jar', + 'fabric-events-interaction-v0': 'fabric-events-interaction-v0.jar', + 'fabric-networking-api-v1' : 'fabric-networking-api-v1.jar', + 'fabric-rendering-v1' : 'fabric-rendering-v1.jar', + 'fabric-key-mapping-api-v1' : 'fabric-key-mapping-api-v1.jar', + 'fabric-permission-api-v1' : 'fabric-permission-api-v1.jar' +] + tasks.named('shadowJar', ShadowJar).configure { doFirst { delete(fileTree(layout.buildDirectory.dir('libs')) { include('Iris v* [Fabric] *.jar') + include('iris-fabric-*.jar') }) delete(layout.buildDirectory.file("libs/Iris-${project.version}+mc${minecraftVersion}-fabric.jar")) } @@ -225,15 +252,63 @@ tasks.named('shadowJar', ShadowJar).configure { exclude('META-INF/*.SF') exclude('META-INF/*.DSA') exclude('META-INF/*.RSA') + // The only multi-release entry any bundled dependency contributes is an OSGi manifest. Keeping + // it forces `Multi-Release: true` onto the mod jar, which makes the loaders treat the whole + // archive as versioned and re-scan it per release directory for nothing. + exclude('META-INF/versions/**') + addMultiReleaseAttribute.set(false) + // Invalid package name on Java 9+ ('enum' is a keyword); a module-path scan chokes on it. + exclude('org/apache/commons/lang/enum/**') + // Compile-only annotation carriers. Shipping them duplicates packages other mods also ship. + exclude('org/jspecify/**') + exclude('com/google/errorprone/**') + exclude('com/google/j2objc/**') + exclude('org/checkerframework/**') + exclude('org/jetbrains/annotations/**') + exclude('org/intellij/lang/**') + // The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod + // loader has. Shipping it only adds dead classes that reference a missing package. + exclude('art/arcane/iris/platform/bukkit/**') duplicatesStrategy = DuplicatesStrategy.EXCLUDE relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm') relocate('io.sentry', 'art.arcane.iris.shadow.sentry') + // Split-package guard. A co-installed mod that ships any of these at their original coordinates + // puts two copies of the same package on one module layer, which the loaders reject at boot + // (Terra ships paralithic; caffeine and dom4j are common in mod dependency trees). First-party + // art.arcane.volmlib is deliberately NOT relocated. + relocate('com.github.benmanes.caffeine', 'art.arcane.iris.shadow.caffeine') + relocate('com.googlecode.concurrentlinkedhashmap', 'art.arcane.iris.shadow.clhm') + relocate('com.dfsek.paralithic', 'art.arcane.iris.shadow.paralithic') + relocate('org.dom4j', 'art.arcane.iris.shadow.dom4j') + relocate('org.jaxen', 'art.arcane.iris.shadow.jaxen') + relocate('org.zeroturnaround.zip', 'art.arcane.iris.shadow.ztzip') from(project.configurations.named('jij')) { into('META-INF/jars') - rename { String fileName -> fileName.replaceAll(/-[0-9][^-]*\.jar$/, '.jar') } + rename { String fileName -> + String module = nestedFabricApiJars.keySet() + .sort { String key -> -key.length() } + .find { String key -> fileName.startsWith(key + '-') } + if (module == null) { + throw new GradleException("Undeclared Fabric API nested jar: ${fileName}. Add it to " + + 'nestedFabricApiJars and to the jars[] array in fabric.mod.json.') + } + + return nestedFabricApiJars.get(module) + } } } +// The thin `jar`/`remapJar` outputs carry valid fabric.mod.json metadata but bundle zero +// dependencies. Installing one is an instant NoClassDefFoundError, and they sit in build/libs right +// next to the real artifact. shadowJar is the only shippable Fabric jar. +tasks.named('jar').configure { + enabled = false +} + +tasks.matching { it.name == 'remapJar' }.configureEach { + enabled = false +} + tasks.named('assemble').configure { dependsOn(tasks.named('shadowJar')) } diff --git a/adapters/fabric/logs/latest.log b/adapters/fabric/logs/latest.log index b64d57348..1a118e8fd 100644 --- a/adapters/fabric/logs/latest.log +++ b/adapters/fabric/logs/latest.log @@ -1,9 +1,16 @@ -[23:26:54] [Test worker/INFO]: Iris registered custom content provider 'iris_deferred_test' -[23:26:54] [Test worker/ERROR]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial9922444042409438775/iris-dimensions.json is invalid; skipping only that entry -java.lang.IllegalArgumentException: entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial9922444042409438775/iris-dimensions.json has no dimension - at art.arcane.iris.modded.ModdedDimensionRegistryStore.required(ModdedDimensionRegistryStore.java:117) - at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:68) - at art.arcane.iris.modded.ModdedDimensionRegistryStoreTest.malformedEntryDoesNotDiscardHealthyEntries(ModdedDimensionRegistryStoreTest.java:51) +[18:39:39] [Test worker/WARN]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0 +[18:39:39] [Test worker/WARN]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes +[18:39:39] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:definitely_not_a_real_block +[18:39:39] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:minecraft:definitely_not_a_real_block +[18:39:39] [Test worker/INFO]: [STDERR]: [Iris/WARN] Block 'minecraft:oak_log' rejected state 'not_a_property=x'; using its default state +[18:39:39] [Test worker/INFO]: Iris registered custom content provider 'iris_deferred_test' +[18:39:39] [Test worker/WARN]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial4384735382753124957/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial4384735382753124957/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"} +[18:39:39] [Test worker/ERROR]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot17112142340130233657/iris-dimensions.json is corrupt; quarantining it and continuing boot +java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot17112142340130233657/iris-dimensions.json could not be read; refusing to discard persistent worlds + at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:150) + at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:56) + at art.arcane.iris.modded.ModdedDimensionRegistryStore.loadForStartup(ModdedDimensionRegistryStore.java:69) + at art.arcane.iris.modded.ModdedDimensionRegistryStoreTest.startupLoadQuarantinesACorruptRegistryInsteadOfFailingBoot(ModdedDimensionRegistryStoreTest.java:99) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) at java.base/java.lang.reflect.Method.invoke(Method.java:565) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) @@ -46,9 +53,21 @@ java.lang.IllegalArgumentException: entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtf at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[23:26:54] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +Caused by: art.arcane.volmlib.util.json.JSONException: A JSONObject text must end with '}' at 34 [character 35 line 1] + at art.arcane.volmlib.util.json.JSONTokener.syntaxError(JSONTokener.java:414) + at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:145) + at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:345) + at art.arcane.volmlib.util.json.JSONArray.(JSONArray.java:111) + at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:348) + at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:159) + at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:260) + at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:117) + ... 45 more +[18:39:39] [Test worker/ERROR]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost +[18:39:39] [Test worker/ERROR]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot17112142340130233657/iris-dimensions.json.broken-1785623979532 +[18:39:39] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: second disable failed - at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures(ModdedServiceManagerTest.java:55) + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) at java.base/java.lang.reflect.Method.invoke(Method.java:565) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) @@ -91,9 +110,9 @@ java.lang.RuntimeException: second disable failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[23:26:54] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService +[18:39:39] [Test worker/ERROR]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService java.lang.RuntimeException: first disable failed - at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures(ModdedServiceManagerTest.java:54) + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) at java.base/java.lang.reflect.Method.invoke(Method.java:565) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) @@ -136,7 +155,55 @@ java.lang.RuntimeException: first disable failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[23:26:54] [Test worker/ERROR]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[18:39:39] [Test worker/ERROR]: Iris disabled all services with 2 failure(s) +java.lang.RuntimeException: second disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) + Suppressed: java.lang.RuntimeException: first disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) + ... 42 more +[18:39:39] [Test worker/ERROR]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) @@ -181,7 +248,7 @@ java.lang.RuntimeException: cleanup failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[23:26:54] [Test worker/ERROR]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[18:39:39] [Test worker/ERROR]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: enable failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) @@ -229,11 +296,11 @@ java.lang.RuntimeException: enable failed Suppressed: java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ... 42 more -[23:26:54] [Test worker/ERROR]: [worldcheck] server stop request failed +[18:39:39] [Test worker/ERROR]: [worldcheck] server stop request failed java.lang.IllegalStateException: stop request failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:235) - at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:179) - at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:232) + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238) + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:142) + at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:235) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) at java.base/java.lang.reflect.Method.invoke(Method.java:565) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) @@ -276,11 +343,11 @@ java.lang.IllegalStateException: stop request failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[23:26:54] [Test worker/ERROR]: [worldcheck] waiting for server shutdown failed +[18:39:39] [Test worker/ERROR]: [worldcheck] waiting for server shutdown failed java.lang.IllegalStateException: shutdown wait failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:261) - at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:194) - at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:259) + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264) + at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:157) + at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:262) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) at java.base/java.lang.reflect.Method.invoke(Method.java:565) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) @@ -323,11 +390,11 @@ java.lang.IllegalStateException: shutdown wait failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[23:26:54] [Test worker/ERROR]: [worldcheck] check failed +[18:39:39] [Test worker/ERROR]: [worldcheck] check failed java.lang.IllegalStateException: check failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:221) - at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:174) - at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:219) + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224) + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:137) + at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:222) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) at java.base/java.lang.reflect.Method.invoke(Method.java:565) at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) @@ -370,8 +437,8 @@ java.lang.IllegalStateException: check failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[23:26:54] [Test worker/INFO]: Iris registered custom content provider 'iris_discovery_success' -[23:26:54] [Test worker/ERROR]: Iris custom content provider discovery failed +[18:39:39] [Test worker/INFO]: Iris registered custom content provider 'iris_discovery_success' +[18:39:39] [Test worker/WARN]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider) java.lang.RuntimeException: provider init failed at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) @@ -416,5 +483,6 @@ java.lang.RuntimeException: provider init failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) -[23:26:54] [Test worker/INFO]: Iris object undo service ready (bounded to 32 paste(s) per player) -[23:26:54] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered +[18:39:39] [Test worker/INFO]: Iris object undo service ready (bounded to 32 paste(s) per player) +[18:39:39] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered +[18:39:39] [Test worker/INFO]: [STDOUT]: [Iris/INFO] Iris /iris command tree registered diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java index b2855b32c..8856e5cf9 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/FabricModdedLoader.java @@ -38,7 +38,7 @@ import java.io.File; import java.nio.file.Path; public final class FabricModdedLoader implements ModdedLoader { - private static final Identifier TREE_FELLER_PERMISSION = Identifier.fromNamespaceAndPath("iris", "treefeller"); + private static final Identifier TREE_FELLER_PERMISSION = Identifier.fromNamespaceAndPath("irisworldgen", "treefeller"); @Override public String platformName() { @@ -67,6 +67,8 @@ public final class FabricModdedLoader implements ModdedLoader { @Override public void invalidateLevelCache(MinecraftServer server) { + // Intentionally empty: Fabric keeps no cached level view of its own (getAllLevels reads the live + // map). Off-thread readers rely on the ModdedServerLevels snapshot, which the caller republishes. } @Override diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricClient.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricClient.java index f2b05df4c..4eaa7b332 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricClient.java +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricClient.java @@ -47,6 +47,7 @@ public final class IrisFabricClient implements ClientModInitializer { HudElementRegistry.addLast(IrisClient.HUD_ELEMENT_ID, (graphics, delta) -> IrisClientHud.render(graphics)); ClientTickEvents.END_CLIENT_TICK.register(client -> { IrisClient.tick(); + IrisClientHud.tick(); IrisClientKeybinds.pollToggle(); }); } diff --git a/adapters/fabric/src/main/resources/fabric.mod.json b/adapters/fabric/src/main/resources/fabric.mod.json index 5e5387e48..bb8a25631 100644 --- a/adapters/fabric/src/main/resources/fabric.mod.json +++ b/adapters/fabric/src/main/resources/fabric.mod.json @@ -5,10 +5,14 @@ "name": "Iris", "description": "Iris World Generation Engine (Fabric adapter - native chunk generator, explicit Iris world datapack workflow, engine lifecycle)", "authors": ["Arcane Arts (Volmit Software)"], + "contributors": ["cyberpwn", "NextdoorPsycho", "Vatuu"], "contact": { + "homepage": "https://docs.volmit.com/iris/", + "issues": "https://github.com/VolmitSoftware/Iris/issues", "sources": "https://github.com/VolmitSoftware/Iris" }, "license": "GPL-3.0", + "icon": "assets/irisworldgen/icon.png", "environment": "*", "accessWidener": "irisworldgen.accesswidener", "mixins": [ diff --git a/adapters/fabric/src/main/resources/irisworldgen.mixins.json b/adapters/fabric/src/main/resources/irisworldgen.mixins.json index 2e2f8b083..916a8d081 100644 --- a/adapters/fabric/src/main/resources/irisworldgen.mixins.json +++ b/adapters/fabric/src/main/resources/irisworldgen.mixins.json @@ -2,7 +2,7 @@ "required": true, "minVersion": "0.8", "package": "art.arcane.iris.fabric.mixin", - "compatibilityLevel": "JAVA_25", + "compatibilityLevel": "JAVA_21", "mixins": [ "BlockItemMixin", "BlockMixin", diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index 3e4e01f6e..b220a1369 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -225,10 +225,14 @@ tasks.named('shadowJar', ShadowJar).configure { doFirst { delete(fileTree(layout.buildDirectory.dir('libs')) { include('Iris v* [Forge] *.jar') + include('iris-forge-*.jar') }) delete(layout.buildDirectory.file("libs/Iris-${project.version}+mc${minecraftVersion}-forge.jar")) } manifest { + // FML 26.2 has no TOML mixin handling at all (ModFileParser reads neither [[mixins]] nor + // accessTransformers on Forge). This manifest attribute is the only registration mechanism; + // do not move it into mods.toml. attributes('MixinConfigs': 'irisworldgen.entity.mixins.json,irisworldgen.client.mixins.json') } archiveFileName.set(irisArtifactName('Forge', "${minecraftVersion}+${loaderDisplayVersion(forgeVersion)}")) @@ -240,16 +244,43 @@ tasks.named('shadowJar', ShadowJar).configure { exclude('META-INF/*.SF') exclude('META-INF/*.DSA') exclude('META-INF/*.RSA') + // The only multi-release entry any bundled dependency contributes is an OSGi manifest. Keeping + // it forces `Multi-Release: true` onto the mod jar, which makes the loaders treat the whole + // archive as versioned and re-scan it per release directory for nothing. + exclude('META-INF/versions/**') + addMultiReleaseAttribute.set(false) + // Invalid package name on Java 9+ ('enum' is a keyword); a module-path scan chokes on it. exclude('org/apache/commons/lang/enum/**') - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm') - relocate('io.sentry', 'art.arcane.iris.shadow.sentry') + // Compile-only annotation carriers. Shipping them duplicates packages other mods also ship. exclude('org/jspecify/**') exclude('com/google/errorprone/**') exclude('com/google/j2objc/**') exclude('org/checkerframework/**') exclude('org/jetbrains/annotations/**') exclude('org/intellij/lang/**') + // The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod + // loader has. Shipping it only adds dead classes that reference a missing package. + exclude('art/arcane/iris/platform/bukkit/**') + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm') + relocate('io.sentry', 'art.arcane.iris.shadow.sentry') + // Split-package guard. A co-installed mod that ships any of these at their original coordinates + // puts two copies of the same package on one module layer, which the loaders reject at boot + // (Terra ships paralithic; caffeine and dom4j are common in mod dependency trees). First-party + // art.arcane.volmlib is deliberately NOT relocated. + relocate('com.github.benmanes.caffeine', 'art.arcane.iris.shadow.caffeine') + relocate('com.googlecode.concurrentlinkedhashmap', 'art.arcane.iris.shadow.clhm') + relocate('com.dfsek.paralithic', 'art.arcane.iris.shadow.paralithic') + relocate('org.dom4j', 'art.arcane.iris.shadow.dom4j') + relocate('org.jaxen', 'art.arcane.iris.shadow.jaxen') + relocate('org.zeroturnaround.zip', 'art.arcane.iris.shadow.ztzip') +} + +// The thin `jar` output carries valid mods.toml metadata plus the MixinConfigs manifest attribute +// but bundles zero dependencies. Installing it is an instant NoClassDefFoundError, and it sits in +// build/libs right next to the real artifact. shadowJar is the only shippable Forge jar. +tasks.named('jar').configure { + enabled = false } tasks.named('assemble').configure { diff --git a/adapters/forge/logs/debug-1.log.gz b/adapters/forge/logs/debug-1.log.gz index 1ccf5168d..a9b0837da 100644 Binary files a/adapters/forge/logs/debug-1.log.gz and b/adapters/forge/logs/debug-1.log.gz differ diff --git a/adapters/forge/logs/debug-2.log.gz b/adapters/forge/logs/debug-2.log.gz index adc1527fd..fbb57a14b 100644 Binary files a/adapters/forge/logs/debug-2.log.gz and b/adapters/forge/logs/debug-2.log.gz differ diff --git a/adapters/forge/logs/debug-3.log.gz b/adapters/forge/logs/debug-3.log.gz index 68a30d037..1ccf5168d 100644 Binary files a/adapters/forge/logs/debug-3.log.gz and b/adapters/forge/logs/debug-3.log.gz differ diff --git a/adapters/forge/logs/debug-4.log.gz b/adapters/forge/logs/debug-4.log.gz index 69f1fa34e..adc1527fd 100644 Binary files a/adapters/forge/logs/debug-4.log.gz and b/adapters/forge/logs/debug-4.log.gz differ diff --git a/adapters/forge/logs/debug-5.log.gz b/adapters/forge/logs/debug-5.log.gz index 116d4c2e1..68a30d037 100644 Binary files a/adapters/forge/logs/debug-5.log.gz and b/adapters/forge/logs/debug-5.log.gz differ diff --git a/adapters/forge/logs/debug.log b/adapters/forge/logs/debug.log index a1fe55d03..65f9a3f7f 100644 --- a/adapters/forge/logs/debug.log +++ b/adapters/forge/logs/debug.log @@ -1,12 +1,16 @@ -[29Jul2026 23:26:59.053] [Test worker/DEBUG] [io.netty.util.internal.logging.InternalLoggerFactory/]: Using SLF4J as the default logging framework -[29Jul2026 23:26:59.054] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.level: simple -[29Jul2026 23:26:59.054] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.targetRecords: 4 -[29Jul2026 23:27:01.214] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' -[29Jul2026 23:27:01.233] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial3400051221726996941/iris-dimensions.json is invalid; skipping only that entry -java.lang.IllegalArgumentException: entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial3400051221726996941/iris-dimensions.json has no dimension - at art.arcane.iris.modded.ModdedDimensionRegistryStore.required(ModdedDimensionRegistryStore.java:117) ~[main/:?] - at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:68) ~[main/:?] - at art.arcane.iris.modded.ModdedDimensionRegistryStoreTest.malformedEntryDoesNotDiscardHealthyEntries(ModdedDimensionRegistryStoreTest.java:51) ~[test/:?] +[01Aug2026 18:39:44.767] [Test worker/DEBUG] [io.netty.util.internal.logging.InternalLoggerFactory/]: Using SLF4J as the default logging framework +[01Aug2026 18:39:44.769] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.level: simple +[01Aug2026 18:39:44.769] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.targetRecords: 4 +[01Aug2026 18:39:47.191] [Test worker/WARN] [Iris/]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0 +[01Aug2026 18:39:47.192] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes +[01Aug2026 18:39:47.231] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' +[01Aug2026 18:39:47.239] [Test worker/WARN] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial16141925079698469919/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial16141925079698469919/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"} +[01Aug2026 18:39:47.253] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot1790066103332136212/iris-dimensions.json is corrupt; quarantining it and continuing boot +java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot1790066103332136212/iris-dimensions.json could not be read; refusing to discard persistent worlds + at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:150) ~[main/:?] + at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:56) ~[main/:?] + at art.arcane.iris.modded.ModdedDimensionRegistryStore.loadForStartup(ModdedDimensionRegistryStore.java:69) ~[main/:?] + at art.arcane.iris.modded.ModdedDimensionRegistryStoreTest.startupLoadQuarantinesACorruptRegistryInsteadOfFailingBoot(ModdedDimensionRegistryStoreTest.java:99) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -49,9 +53,21 @@ java.lang.IllegalArgumentException: entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtf at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.309] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +Caused by: art.arcane.volmlib.util.json.JSONException: A JSONObject text must end with '}' at 34 [character 35 line 1] + at art.arcane.volmlib.util.json.JSONTokener.syntaxError(JSONTokener.java:414) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:145) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:345) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.volmlib.util.json.JSONArray.(JSONArray.java:111) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:348) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:159) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:260) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:117) ~[main/:?] + ... 45 more +[01Aug2026 18:39:47.262] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost +[01Aug2026 18:39:47.262] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot1790066103332136212/iris-dimensions.json.broken-1785623987262 +[01Aug2026 18:39:47.318] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: second disable failed - at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures(ModdedServiceManagerTest.java:55) ~[test/:?] + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -94,9 +110,9 @@ java.lang.RuntimeException: second disable failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.313] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService +[01Aug2026 18:39:47.322] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService java.lang.RuntimeException: first disable failed - at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures(ModdedServiceManagerTest.java:54) ~[test/:?] + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -139,7 +155,55 @@ java.lang.RuntimeException: first disable failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.316] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[01Aug2026 18:39:47.329] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s) +java.lang.RuntimeException: second disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] + Suppressed: java.lang.RuntimeException: first disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?] + ... 42 more +[01Aug2026 18:39:47.334] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -184,7 +248,7 @@ java.lang.RuntimeException: cleanup failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.320] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[01Aug2026 18:39:47.337] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: enable failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -232,11 +296,11 @@ java.lang.RuntimeException: enable failed Suppressed: java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] ... 42 more -[29Jul2026 23:27:01.357] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed +[01Aug2026 18:39:47.373] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed java.lang.IllegalStateException: stop request failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:235) ~[test/:?] - at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:179) ~[main/:?] - at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:232) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:142) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:235) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -279,11 +343,11 @@ java.lang.IllegalStateException: stop request failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.360] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed +[01Aug2026 18:39:47.376] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed java.lang.IllegalStateException: shutdown wait failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:261) ~[test/:?] - at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:194) ~[main/:?] - at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:259) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:157) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:262) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -326,11 +390,11 @@ java.lang.IllegalStateException: shutdown wait failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.365] [Test worker/ERROR] [Iris/]: [worldcheck] check failed +[01Aug2026 18:39:47.382] [Test worker/ERROR] [Iris/]: [worldcheck] check failed java.lang.IllegalStateException: check failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:221) ~[test/:?] - at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:174) ~[main/:?] - at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:219) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:137) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:222) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -373,8 +437,8 @@ java.lang.IllegalStateException: check failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.373] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' -[29Jul2026 23:27:01.373] [Test worker/ERROR] [Iris/]: Iris custom content provider discovery failed +[01Aug2026 18:39:47.390] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' +[01Aug2026 18:39:47.391] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider) java.lang.RuntimeException: provider init failed at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -419,4 +483,4 @@ java.lang.RuntimeException: provider init failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.397] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player) +[01Aug2026 18:39:47.408] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player) diff --git a/adapters/forge/logs/latest.log b/adapters/forge/logs/latest.log index f68f35db9..a0be3b6a4 100644 --- a/adapters/forge/logs/latest.log +++ b/adapters/forge/logs/latest.log @@ -1,9 +1,13 @@ -[29Jul2026 23:27:01.214] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' -[29Jul2026 23:27:01.233] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial3400051221726996941/iris-dimensions.json is invalid; skipping only that entry -java.lang.IllegalArgumentException: entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial3400051221726996941/iris-dimensions.json has no dimension - at art.arcane.iris.modded.ModdedDimensionRegistryStore.required(ModdedDimensionRegistryStore.java:117) ~[main/:?] - at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:68) ~[main/:?] - at art.arcane.iris.modded.ModdedDimensionRegistryStoreTest.malformedEntryDoesNotDiscardHealthyEntries(ModdedDimensionRegistryStoreTest.java:51) ~[test/:?] +[01Aug2026 18:39:47.191] [Test worker/WARN] [Iris/]: Iris cannot resolve the biome id for 'minecraft:plains' before the Minecraft server is available; using biome id 0 +[01Aug2026 18:39:47.192] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes +[01Aug2026 18:39:47.231] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' +[01Aug2026 18:39:47.239] [Test worker/WARN] [Iris/]: Iris persistent dimension registry entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial16141925079698469919/iris-dimensions.json is invalid (entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial16141925079698469919/iris-dimensions.json has no dimension); kept verbatim: {"id":"iris:broken","pack":"overworld"} +[01Aug2026 18:39:47.253] [Test worker/ERROR] [Iris/]: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot1790066103332136212/iris-dimensions.json is corrupt; quarantining it and continuing boot +java.lang.IllegalStateException: Iris persistent dimension registry at /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot1790066103332136212/iris-dimensions.json could not be read; refusing to discard persistent worlds + at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:150) ~[main/:?] + at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:56) ~[main/:?] + at art.arcane.iris.modded.ModdedDimensionRegistryStore.loadForStartup(ModdedDimensionRegistryStore.java:69) ~[main/:?] + at art.arcane.iris.modded.ModdedDimensionRegistryStoreTest.startupLoadQuarantinesACorruptRegistryInsteadOfFailingBoot(ModdedDimensionRegistryStoreTest.java:99) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -46,9 +50,21 @@ java.lang.IllegalArgumentException: entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtf at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.309] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +Caused by: art.arcane.volmlib.util.json.JSONException: A JSONObject text must end with '}' at 34 [character 35 line 1] + at art.arcane.volmlib.util.json.JSONTokener.syntaxError(JSONTokener.java:414) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:145) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:345) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.volmlib.util.json.JSONArray.(JSONArray.java:111) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:348) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:159) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.volmlib.util.json.JSONObject.(JSONObject.java:260) ~[shared-local-SNAPSHOT.jar:?] + at art.arcane.iris.modded.ModdedDimensionRegistryStore.contents(ModdedDimensionRegistryStore.java:117) ~[main/:?] + ... 45 more +[01Aug2026 18:39:47.262] [Test worker/ERROR] [Iris/]: Iris lost 1 persistent dimension(s) from the corrupt registry: iris:lost +[01Aug2026 18:39:47.262] [Test worker/ERROR] [Iris/]: Iris moved the corrupt persistent dimension registry to /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-corrupt-boot1790066103332136212/iris-dimensions.json.broken-1785623987262 +[01Aug2026 18:39:47.318] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: second disable failed - at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures(ModdedServiceManagerTest.java:55) ~[test/:?] + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -91,9 +107,9 @@ java.lang.RuntimeException: second disable failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.313] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService +[01Aug2026 18:39:47.322] [Test worker/ERROR] [Iris/]: Iris service onDisable failed for art.arcane.iris.modded.ModdedServiceManagerTest$FirstService java.lang.RuntimeException: first disable failed - at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures(ModdedServiceManagerTest.java:54) ~[test/:?] + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -136,7 +152,55 @@ java.lang.RuntimeException: first disable failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.316] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[01Aug2026 18:39:47.329] [Test worker/ERROR] [Iris/]: Iris disabled all services with 2 failure(s) +java.lang.RuntimeException: second disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:55) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] + Suppressed: java.lang.RuntimeException: first disable failed + at art.arcane.iris.modded.ModdedServiceManagerTest.disableAttemptsEveryServiceInReverseOrderAndNeverRethrows(ModdedServiceManagerTest.java:54) ~[test/:?] + ... 42 more +[01Aug2026 18:39:47.334] [Test worker/ERROR] [Iris/]: Iris service rollback failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -181,7 +245,7 @@ java.lang.RuntimeException: cleanup failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.320] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService +[01Aug2026 18:39:47.337] [Test worker/ERROR] [Iris/]: Iris service onEnable failed for art.arcane.iris.modded.ModdedServiceManagerTest$SecondService java.lang.RuntimeException: enable failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -229,11 +293,11 @@ java.lang.RuntimeException: enable failed Suppressed: java.lang.RuntimeException: cleanup failed at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?] ... 42 more -[29Jul2026 23:27:01.357] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed +[01Aug2026 18:39:47.373] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed java.lang.IllegalStateException: stop request failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:235) ~[test/:?] - at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:179) ~[main/:?] - at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:232) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:142) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:235) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -276,11 +340,11 @@ java.lang.IllegalStateException: stop request failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.360] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed +[01Aug2026 18:39:47.376] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed java.lang.IllegalStateException: shutdown wait failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:261) ~[test/:?] - at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:194) ~[main/:?] - at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:259) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:157) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:262) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -323,11 +387,11 @@ java.lang.IllegalStateException: shutdown wait failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.365] [Test worker/ERROR] [Iris/]: [worldcheck] check failed +[01Aug2026 18:39:47.382] [Test worker/ERROR] [Iris/]: [worldcheck] check failed java.lang.IllegalStateException: check failed - at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:221) ~[test/:?] - at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:174) ~[main/:?] - at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:219) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:137) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:222) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] @@ -370,8 +434,8 @@ java.lang.IllegalStateException: check failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.373] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' -[29Jul2026 23:27:01.373] [Test worker/ERROR] [Iris/]: Iris custom content provider discovery failed +[01Aug2026 18:39:47.390] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_discovery_success' +[01Aug2026 18:39:47.391] [Test worker/WARN] [Iris/]: Iris custom content provider discovery failed at provider 'iris_discovery_failure' (art.arcane.iris.modded.api.ModdedCustomContentRegistryTest$TestProvider) java.lang.RuntimeException: provider init failed at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] @@ -416,4 +480,4 @@ java.lang.RuntimeException: provider init failed at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] -[29Jul2026 23:27:01.397] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player) +[01Aug2026 18:39:47.408] [Test worker/INFO] [Iris/]: Iris object undo service ready (bounded to 32 paste(s) per player) diff --git a/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java b/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java index 50cfcbf4c..663cfacb6 100644 --- a/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java +++ b/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeModdedLoader.java @@ -43,7 +43,7 @@ import java.nio.file.Path; public final class ForgeModdedLoader implements ModdedLoader { public static final PermissionNode TREE_FELLER_PERMISSION = new PermissionNode<>( - "iris", + "irisworldgen", "treefeller", PermissionTypes.BOOLEAN, (player, playerId, contexts) -> diff --git a/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeProtocolNetworking.java b/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeProtocolNetworking.java index e63f14f77..c04f252af 100644 --- a/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeProtocolNetworking.java +++ b/adapters/forge/src/main/java/art/arcane/iris/forge/ForgeProtocolNetworking.java @@ -75,7 +75,10 @@ public final class ForgeProtocolNetworking { @Override public boolean canReceive(ServerPlayer player) { - return true; + // Forge tracks the channels the remote side announced during handshake. Without this the + // server pushes Iris payloads at vanilla clients, which drop them as unknown custom + // payloads. Mirrors the NeoForge ICommonPacketListener.hasChannel check. + return channel.isRemotePresent(player.connection.getConnection()); } @Override diff --git a/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java index 9c7f9e8bd..cea8c2a47 100644 --- a/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java +++ b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java @@ -70,6 +70,10 @@ public final class IrisForgeBootstrap { ForgeProtocolNetworking.register(); + // Dist gate for every client-tainted class. DistExecutor is gone in Forge 26.2 (65.x) - only + // net.minecraftforge.api.distmarker.Dist survives, from mergetool-api - so the guard is this branch. + // It is equally safe: IrisForgeClient appears only as an invokestatic target, so the JVM resolves the + // constant-pool entry lazily on first execution and a dedicated server never loads the class. if (FMLEnvironment.dist == Dist.CLIENT) { IrisForgeClient.init(); } diff --git a/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeClient.java b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeClient.java index fc3cf213b..0dedbcdd4 100644 --- a/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeClient.java +++ b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeClient.java @@ -47,8 +47,11 @@ public final class IrisForgeClient { ClientPlayerNetworkEvent.LoggingIn.BUS.addListener((ClientPlayerNetworkEvent.LoggingIn event) -> IrisClient.onWorldJoin()); ClientPlayerNetworkEvent.LoggingOut.BUS.addListener((ClientPlayerNetworkEvent.LoggingOut event) -> IrisClient.onDisconnect()); InputEvent.Key.BUS.addListener((InputEvent.Key event) -> IrisClientKeybinds.pollToggle()); - TickEvent.ClientTickEvent.Post.BUS.addListener( - (TickEvent.ClientTickEvent.Post event) -> IrisClient.tick()); + TickEvent.ClientTickEvent.Post.BUS.addListener((TickEvent.ClientTickEvent.Post event) -> { + IrisClientKeybinds.pollToggle(); + IrisClient.tick(); + IrisClientHud.tick(); + }); } private static void sendToServer(byte[] frame) { diff --git a/adapters/forge/src/main/resources/META-INF/mods.toml b/adapters/forge/src/main/resources/META-INF/mods.toml index 67c54b572..a55737f00 100644 --- a/adapters/forge/src/main/resources/META-INF/mods.toml +++ b/adapters/forge/src/main/resources/META-INF/mods.toml @@ -1,6 +1,7 @@ modLoader = "javafml" -loaderVersion = "[65,)" +loaderVersion = "[65,66)" license = "GPL-3.0" +issueTrackerURL = "https://github.com/VolmitSoftware/Iris/issues" [[mods]] modId = "irisworldgen" @@ -8,11 +9,14 @@ version = "${version}" displayName = "Iris" description = "Iris World Generation Engine (Forge adapter - native chunk generator, explicit Iris world datapack workflow, engine lifecycle)" authors = "Arcane Arts (Volmit Software)" +credits = "cyberpwn, NextdoorPsycho, Vatuu" +displayURL = "https://docs.volmit.com/iris/" +logoFile = "assets/irisworldgen/icon.png" [[dependencies.irisworldgen]] modId = "forge" mandatory = true -versionRange = "[65,)" +versionRange = "[65,66)" ordering = "NONE" side = "BOTH" diff --git a/adapters/forge/src/main/resources/pack.mcmeta b/adapters/forge/src/main/resources/pack.mcmeta deleted file mode 100644 index 0d765cb51..000000000 --- a/adapters/forge/src/main/resources/pack.mcmeta +++ /dev/null @@ -1,10 +0,0 @@ -{ - "pack": { - "description": "Iris World Generation Engine resources", - "max_format": 101, - "min_format": [ - 101, - 1 - ] - } -} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/InitialSpawnQueue.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/InitialSpawnQueue.java index 16ebff113..46b903c71 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/InitialSpawnQueue.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/InitialSpawnQueue.java @@ -33,6 +33,7 @@ final class InitialSpawnQueue { private final long maxAgeNanos; private final LongSupplier nanoTime; private final ArrayDeque queue; + private final ArrayDeque expiryOrder; private final Map pending; private final Set queued; private boolean closed; @@ -52,6 +53,7 @@ final class InitialSpawnQueue { this.maxAgeNanos = maxAgeNanos; this.nanoTime = nanoTime; this.queue = new ArrayDeque<>(Math.min(capacity, 256)); + this.expiryOrder = new ArrayDeque<>(Math.min(capacity, 256)); this.pending = new HashMap<>(); this.queued = new HashSet<>(); } @@ -66,9 +68,12 @@ final class InitialSpawnQueue { if (pending.size() >= capacity) { return false; } - pending.put(key, nanoTime.getAsLong()); - queued.add(key); - queue.addLast(key); + long offeredAt = nanoTime.getAsLong(); + pending.put(key, offeredAt); + expiryOrder.addLast(new Expiry(key, offeredAt)); + if (queued.add(key)) { + queue.addLast(key); + } return true; } @@ -92,8 +97,10 @@ final class InitialSpawnQueue { pending.remove(key); continue; } + expire(now); return key; } + expire(now); return null; } @@ -115,6 +122,7 @@ final class InitialSpawnQueue { if (queued.remove(key)) { queue.removeFirstOccurrence(key); } + expire(nanoTime.getAsLong()); } synchronized boolean isEmpty() { @@ -127,6 +135,7 @@ final class InitialSpawnQueue { synchronized void clear() { queue.clear(); + expiryOrder.clear(); pending.clear(); queued.clear(); } @@ -136,24 +145,41 @@ final class InitialSpawnQueue { clear(); } + /** + * Drops offers that aged out, plus entries for keys that already left {@code pending}. + * {@code expiryOrder} is append-only and {@code nanoTime} is monotonic, so the deque is sorted by + * offer time: the loop stops at the first live, unexpired entry. That makes the saturated-queue + * call O(1) in the common case instead of the O(capacity) scan it used to be under the monitor, + * and running it from poll/complete keeps the deque from growing past the live offers. + * + *

Expired keys are normally left in {@code queue}/{@code queued}: {@link #poll()} already drops keys + * that are no longer pending, so the drain deque self-cleans without an O(n) sweep. That only holds while + * something polls - a producer that keeps offering into a queue nobody drains would grow both past + * {@code capacity} forever - so once the deque is over capacity it is swept against {@code pending} here. + */ private void expire(long now) { - Set expired = new HashSet<>(); - for (Map.Entry entry : pending.entrySet()) { - if (expired(entry.getValue(), now)) { - expired.add(entry.getKey()); + Expiry head; + while ((head = expiryOrder.peekFirst()) != null) { + Long offeredAt = pending.get(head.key()); + boolean live = offeredAt != null && offeredAt.longValue() == head.offeredAt(); + if (live && !expired(head.offeredAt(), now)) { + break; + } + expiryOrder.pollFirst(); + if (live) { + pending.remove(head.key()); } } - if (expired.isEmpty()) { - return; + if (queue.size() > capacity) { + queue.removeIf(key -> !pending.containsKey(key)); + queued.retainAll(pending.keySet()); } - for (Long key : expired) { - pending.remove(key); - queued.remove(key); - } - queue.removeIf(expired::contains); } private boolean expired(long offeredAt, long now) { return now - offeredAt >= maxAgeNanos; } + + private record Expiry(long key, long offeredAt) { + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java index 80b796695..dc51dac6a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java @@ -40,25 +40,33 @@ import net.minecraft.world.level.biome.Climate; import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructureSet; +import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; +import java.util.List; import java.util.Locale; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReferenceArray; import java.util.stream.Stream; final class IrisModdedBiomeSource extends BiomeSource { - private static final int BIOME_CACHE_MAX = 262144; private static final int UNRESOLVED_WARN_KEYS_MAX = 256; private final BiomeSource serializedSource; private final Set warnedUnresolvedBiomeKeys = ConcurrentHashMap.newKeySet(); - private final ConcurrentHashMap> visibleBiomeCache = new ConcurrentHashMap<>(); - private final ConcurrentHashMap> structureBiomeCache = new ConcurrentHashMap<>(); - private final ConcurrentHashMap> surfaceStructureBiomeCache = new ConcurrentHashMap<>(); private final Set structureStateSources = ConcurrentHashMap.newKeySet(); + // Pack generation. Every cache on this source is keyed by it, which is what keeps memoized tables from + // surviving a repoint with the previous pack's content. + private final AtomicLong packGeneration = new AtomicLong(); + private volatile BiomeHolderTable visibleBiomeCache = new BiomeHolderTable(); + private volatile BiomeHolderTable structureBiomeCache = new BiomeHolderTable(); + private volatile BiomeHolderTable surfaceStructureBiomeCache = new BiomeHolderTable(); private volatile IrisModdedChunkGenerator generator; private volatile Set possibleStructureBiomeKeys; + private volatile BiomeKeySets biomeKeySets; + private volatile PossibleBiomes possibleBiomesCache; IrisModdedBiomeSource(BiomeSource serializedSource) { this.serializedSource = serializedSource; @@ -69,16 +77,31 @@ final class IrisModdedBiomeSource extends BiomeSource { } void clearCaches() { - visibleBiomeCache.clear(); - structureBiomeCache.clear(); - surfaceStructureBiomeCache.clear(); + // Republish empty tables instead of iterating: a repoint must not walk hundreds of thousands of slots + // on the calling thread, and every reader is a pure function of the key so a lost entry is only a miss. + // An empty table allocates no slot array, and a reader that captured the previous table writes its + // in-flight value there, which is what keeps a pre-repoint holder from ever landing in the new table. + packGeneration.incrementAndGet(); + visibleBiomeCache = new BiomeHolderTable(); + structureBiomeCache = new BiomeHolderTable(); + surfaceStructureBiomeCache = new BiomeHolderTable(); warnedUnresolvedBiomeKeys.clear(); possibleStructureBiomeKeys = null; + biomeKeySets = null; + possibleBiomesCache = null; for (StructureStateBiomeSource source : structureStateSources) { source.clearCache(); } } + /** + * Pack generation counter. Platform code that memoizes anything derived from this source (the imported + * feature table) keys its memo on this value so {@code repoint} cannot leave stale content behind. + */ + long packGeneration() { + return packGeneration.get(); + } + BiomeSource forStructureState(HolderLookup structureSets) { LinkedHashSet> possible = new LinkedHashSet<>(); Registry registry = biomeRegistry(); @@ -113,12 +136,68 @@ final class IrisModdedBiomeSource extends BiomeSource { @Override protected Stream> collectPossibleBiomes() { - Set generatedBiomeKeys = requireConfiguredStructureBiomeKeys(exactStructureBiomeKeys()); + return resolvePossibleBiomes().ordered().stream(); + } + + /** + * Overridden because {@link BiomeSource#possibleBiomes()} memoizes its answer for the lifetime of the + * instance, and this source outlives a {@code repoint} to a different pack. The returned set keeps + * biome-registry iteration order: vanilla builds its feature-per-step table with + * {@code List.copyOf(possibleBiomes())}, and FeatureSorter's cycle detection walks that list, so an + * unordered set makes cycle detection depend on JVM hash order. + */ + @Override + public Set> possibleBiomes() { + return resolvePossibleBiomes().set(); + } + + /** + * Registry-ordered view of {@link #possibleBiomes()} for platform code that has to build a feature table. + */ + List> orderedPossibleBiomes() { + return resolvePossibleBiomes().ordered(); + } + + /** + * Resolves any registered biome holder by key, whether or not this source can emit it. The imported feature + * pass needs this: a biome's vanilla derivative is where its features come from, and a sea or shore biome's + * structure derivative is rewritten away from that derivative, so the derivative itself is not always one of + * the biomes this source claims. Null when the key is unknown or the registry is not up yet. + */ + Holder registeredBiome(String key) { + Registry registry = biomeRegistry(); + return registry == null ? null : resolveHolder(registry, key); + } + + /** + * Deliberately lock-free: resolving can bind an engine, which takes the generator monitor, and the + * generator takes its monitor before invalidating this cache. A lock here would close that cycle. Two + * threads racing only duplicate idempotent work. + */ + private PossibleBiomes resolvePossibleBiomes() { + long generation = packGeneration.get(); + PossibleBiomes cached = possibleBiomesCache; + if (cached != null && cached.generation() == generation) { + return cached; + } + List> ordered = collectPossibleBiomeHolders(); + PossibleBiomes resolved = new PossibleBiomes(generation, ordered, + Collections.unmodifiableSet(new LinkedHashSet<>(ordered))); + possibleBiomesCache = resolved; + return resolved; + } + + private List> collectPossibleBiomeHolders() { + BiomeKeySets keys = biomeKeySets(); + Set generatedBiomeKeys = requireConfiguredStructureBiomeKeys(keys.required()); + Set visibleBiomeKeys = keys.visibleOnly(); Registry registry = biomeRegistry(); LinkedHashSet> possible = new LinkedHashSet<>(); if (registry == null) { for (Holder biome : serializedSource.possibleBiomes()) { - if (isGeneratedBiomeKey(holderKey(biome), generatedBiomeKeys)) { + String key = holderKey(biome); + if (isGeneratedBiomeKey(key, generatedBiomeKeys) + || isGeneratedBiomeKey(key, visibleBiomeKeys)) { possible.add(biome); } } @@ -129,18 +208,44 @@ final class IrisModdedBiomeSource extends BiomeSource { throw new IllegalStateException("Iris structure biomes are not registered: " + missingBiomeKeys); } + // Registry-ordered, never a hash-ordered walk: see possibleBiomes(). registry.listElements().forEach((Holder.Reference reference) -> { - if (isGeneratedBiomeKey(holderKey(reference), generatedBiomeKeys)) { + String key = holderKey(reference); + if (isGeneratedBiomeKey(key, generatedBiomeKeys) + || isGeneratedBiomeKey(key, visibleBiomeKeys)) { possible.add(reference); } }); + warnUnregisteredVisibleBiomes(registry, visibleBiomeKeys); } if (possible.isEmpty()) { String phase = registry == null ? "serialized biome bootstrap" : "biome registry"; throw new IllegalStateException("Iris configured structure biomes are absent from the " + phase + ": " + generatedBiomeKeys); } - return possible.stream(); + return List.copyOf(possible); + } + + /** + * Scatter and derivative keys are advisory: a typo there must not stop a world from loading the way a + * missing structure biome does, so they are reported once and skipped. + */ + private void warnUnregisteredVisibleBiomes(Registry registry, Set visibleBiomeKeys) { + if (visibleBiomeKeys.isEmpty()) { + return; + } + Set missing = new LinkedHashSet<>(visibleBiomeKeys); + missing.removeAll(registeredBiomeKeys(registry)); + for (String key : missing) { + if (!warnedUnresolvedBiomeKeys.add(key)) { + continue; + } + if (warnedUnresolvedBiomeKeys.size() > UNRESOLVED_WARN_KEYS_MAX) { + warnedUnresolvedBiomeKeys.clear(); + } + ModdedIrisLog.warn("Iris biome " + key + " is referenced by derivative or scatter but is not" + + " registered; it is dropped from this dimension's biome source"); + } } @Override @@ -167,16 +272,14 @@ final class IrisModdedBiomeSource extends BiomeSource { return getSurfaceStructureBiome(engine, quartX, quartZ, sampler); } long key = packNoiseKey(quartX, quartY, quartZ); - Holder cached = structureBiomeCache.get(key); + BiomeHolderTable cache = structureBiomeCache; + Holder cached = cache.get(key); if (cached != null) { return cached; } Holder resolved = resolveStructureBiome(engine, quartX, quartY, quartZ, sampler); - Holder existing = structureBiomeCache.putIfAbsent(key, resolved); - if (structureBiomeCache.size() > BIOME_CACHE_MAX) { - structureBiomeCache.clear(); - } - return existing == null ? resolved : existing; + cache.put(key, resolved); + return resolved; } Holder getVisibleNoiseBiome(int quartX, int quartY, int quartZ, Climate.Sampler sampler) { @@ -193,16 +296,14 @@ final class IrisModdedBiomeSource extends BiomeSource { throw new IllegalStateException("Iris visible biome lookup has no active engine runtime"); } long key = packNoiseKey(quartX, quartY, quartZ); - Holder cached = visibleBiomeCache.get(key); + BiomeHolderTable cache = visibleBiomeCache; + Holder cached = cache.get(key); if (cached != null) { return cached; } Holder resolved = resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler); - Holder existing = visibleBiomeCache.putIfAbsent(key, resolved); - if (visibleBiomeCache.size() > BIOME_CACHE_MAX) { - visibleBiomeCache.clear(); - } - return existing == null ? resolved : existing; + cache.put(key, resolved); + return resolved; } } @@ -268,16 +369,14 @@ final class IrisModdedBiomeSource extends BiomeSource { private Holder getSurfaceStructureBiome(Engine engine, int quartX, int quartZ, Climate.Sampler sampler) { long key = packColumnKey(quartX, quartZ); - Holder cached = surfaceStructureBiomeCache.get(key); + BiomeHolderTable cache = surfaceStructureBiomeCache; + Holder cached = cache.get(key); if (cached != null) { return cached; } Holder resolved = resolveSurfaceStructureBiome(engine, quartX, quartZ, sampler); - Holder existing = surfaceStructureBiomeCache.putIfAbsent(key, resolved); - if (surfaceStructureBiomeCache.size() > BIOME_CACHE_MAX) { - surfaceStructureBiomeCache.clear(); - } - return existing == null ? resolved : existing; + cache.put(key, resolved); + return resolved; } private Holder resolveSurfaceStructureBiome(Engine engine, int quartX, int quartZ, @@ -500,13 +599,35 @@ final class IrisModdedBiomeSource extends BiomeSource { } private Set exactStructureBiomeKeys() { + return biomeKeySets().required(); + } + + /** + * Required and visible-only biome keys for the current pack generation. Required keys are the structure + * derivatives and the generated custom biomes: a missing one is fatal, exactly as before. Visible-only + * keys are the raw derivative plus every {@code biomeScatter} and {@code biomeSkyScatter} entry - biomes + * Iris writes into chunk sections but which vanilla previously dropped, because + * {@code applyBiomeDecoration} intersects the chunk's biomes with {@code possibleBiomes()}. + */ + private BiomeKeySets biomeKeySets() { + long generation = packGeneration.get(); + BiomeKeySets cached = biomeKeySets; + if (cached != null && cached.generation() == generation) { + return cached; + } + BiomeKeySets resolved = collectBiomeKeySets(generation); + biomeKeySets = resolved; + return resolved; + } + + private BiomeKeySets collectBiomeKeySets(long generation) { IrisModdedChunkGenerator current = generator; if (current == null) { - return Set.of(); + return new BiomeKeySets(generation, Set.of(), Set.of()); } Engine engine = current.structureEngineOrNull(); if (engine == null) { - return current.configuredStructureBiomeKeys(); + return new BiomeKeySets(generation, current.configuredStructureBiomeKeys(), Set.of()); } GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_structure_biome_keys"); if (lease == null) { @@ -516,20 +637,35 @@ final class IrisModdedBiomeSource extends BiomeSource { if (!isReady(engine)) { throw new IllegalStateException("Iris structure biome key lookup has no active engine runtime"); } - LinkedHashSet possible = new LinkedHashSet<>(); + LinkedHashSet required = new LinkedHashSet<>(); + LinkedHashSet visible = new LinkedHashSet<>(); for (IrisBiome irisBiome : engine.getAllBiomes()) { String derivative = normalizeKey(irisBiome.getStructureDerivativeKey()); if (derivative != null) { - possible.add(derivative); + required.add(derivative); } - if (!irisBiome.isCustom()) { - continue; + if (irisBiome.isCustom()) { + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + required.add(ModdedWorldgenIds.biomeRef(engine, customBiome.getId())); + } } - for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { - possible.add(ModdedWorldgenIds.biomeRef(engine, customBiome.getId())); + addVisibleBiomeKey(visible, irisBiome.getDerivativeKey()); + for (String scatter : irisBiome.getBiomeScatter()) { + addVisibleBiomeKey(visible, scatter); + } + for (String scatter : irisBiome.getBiomeSkyScatter()) { + addVisibleBiomeKey(visible, scatter); } } - return Set.copyOf(possible); + visible.removeAll(required); + return new BiomeKeySets(generation, Set.copyOf(required), Set.copyOf(visible)); + } + } + + private static void addVisibleBiomeKey(Set target, String key) { + String normalized = normalizeKey(key); + if (normalized != null) { + target.add(normalized); } } @@ -608,10 +744,90 @@ final class IrisModdedBiomeSource extends BiomeSource { int blockZ, RNG rng) { } + private record BiomeKeySets(long generation, Set required, Set visibleOnly) { + } + + private record PossibleBiomes(long generation, List> ordered, Set> set) { + } + + /** + * Fixed-capacity open-addressed long-to-holder cache. Sized once and never resized, so there is no + * stop-the-world clear when it fills: a colliding write past the probe limit simply displaces the entry at + * the home slot, and the displaced key resolves again on its next lookup. Every cached value is a pure + * function of its key, so displacement can only cost work, never correctness. Entries are published as one + * immutable record, which is what keeps a concurrent writer from ever pairing one key with another's value. + * + *

The slot array is installed on the first put, never in the field initializer. One biome source is + * constructed per emitted world preset at datapack load, and every create-world screen builds them all, so + * eager arrays cost tens of megabytes of tables that nothing ever reads. An empty table answers every get + * with a miss, which is the same answer a cold table gives. + */ + private static final class BiomeHolderTable { + private static final int SLOTS = 32768; + private static final int MASK = SLOTS - 1; + private static final int PROBE_LIMIT = 8; + + private volatile AtomicReferenceArray table; + + Holder get(long key) { + AtomicReferenceArray slots = table; + if (slots == null) { + return null; + } + int home = home(key); + for (int probe = 0; probe < PROBE_LIMIT; probe++) { + Entry entry = slots.get((home + probe) & MASK); + if (entry == null) { + return null; + } + if (entry.key() == key) { + return entry.value(); + } + } + return null; + } + + void put(long key, Holder value) { + AtomicReferenceArray slots = table; + if (slots == null) { + slots = install(); + } + int home = home(key); + Entry entry = new Entry(key, value); + for (int probe = 0; probe < PROBE_LIMIT; probe++) { + int slot = (home + probe) & MASK; + Entry existing = slots.get(slot); + if (existing == null || existing.key() == key) { + slots.set(slot, entry); + return; + } + } + slots.set(home, entry); + } + + private synchronized AtomicReferenceArray install() { + AtomicReferenceArray existing = table; + if (existing != null) { + return existing; + } + AtomicReferenceArray created = new AtomicReferenceArray<>(SLOTS); + table = created; + return created; + } + + private static int home(long key) { + long mixed = key * 0x9E3779B97F4A7C15L; + return (int) ((mixed ^ (mixed >>> 32)) & MASK); + } + + private record Entry(long key, Holder value) { + } + } + private static final class StructureStateBiomeSource extends BiomeSource { private final IrisModdedBiomeSource delegate; private final Set> possibleBiomes; - private final ConcurrentHashMap> resolvedBiomes = new ConcurrentHashMap<>(); + private volatile BiomeHolderTable resolvedBiomes = new BiomeHolderTable(); private StructureStateBiomeSource(IrisModdedBiomeSource delegate, Set> possibleBiomes) { this.delegate = delegate; @@ -619,7 +835,7 @@ final class IrisModdedBiomeSource extends BiomeSource { } private void clearCache() { - resolvedBiomes.clear(); + resolvedBiomes = new BiomeHolderTable(); } @Override @@ -635,16 +851,14 @@ final class IrisModdedBiomeSource extends BiomeSource { @Override public Holder getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { long key = packNoiseKey(x, y, z); - Holder cached = resolvedBiomes.get(key); + BiomeHolderTable cache = resolvedBiomes; + Holder cached = cache.get(key); if (cached != null) { return cached; } Holder resolved = delegate.resolveRequiredStructureBiome(x, y, z); - Holder existing = resolvedBiomes.putIfAbsent(key, resolved); - if (resolvedBiomes.size() > BIOME_CACHE_MAX) { - resolvedBiomes.clear(); - } - return existing == null ? resolved : existing; + cache.put(key, resolved); + return resolved; } @Override diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index 38b827237..6d761ee7f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -21,6 +21,7 @@ package art.arcane.iris.modded; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.NativeFeatureGenerationPolicy; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.NativeStructureStartPlan; @@ -96,6 +97,11 @@ import java.util.function.IntBinaryOperator; public final class IrisModdedChunkGenerator extends ChunkGenerator { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + // Vanilla-shaped fallback for an unbound generator (matches IrisDimension defaults). getMinY, + // getSeaLevel and getGenDepth are called from world creation and client screens, so they must + // answer without disk I/O and without throwing before a level is bound. + private static final ModdedDimensionMetadata.DimensionMetadata UNBOUND_HEIGHTS = + new ModdedDimensionMetadata.DimensionMetadata(-64, 320, 63); public static final MapCodec CODEC = RecordCodecBuilder.mapCodec((RecordCodecBuilder.Instance instance) -> instance.group( BiomeSource.CODEC.fieldOf("biome_source").forGetter((IrisModdedChunkGenerator generator) -> generator.serializedBiomeSource), Codec.STRING.fieldOf("dimension").forGetter((IrisModdedChunkGenerator generator) -> generator.dimensionKey) @@ -117,6 +123,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { private final ModdedEngineBinding engineBinding = new ModdedEngineBinding<>(60L, TimeUnit.SECONDS); private final ModdedNativeStructureStage nativeStructures = new ModdedNativeStructureStage(this); private final ModdedSpawnTableMerger spawnTables = new ModdedSpawnTableMerger(this); + private final ModdedImportedFeatureStage importedFeatures; private final AtomicBoolean announced = new AtomicBoolean(false); private volatile boolean unloading; private volatile Engine engine; @@ -126,13 +133,28 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { private volatile long lastChunkGenAt = 0L; private volatile Set configuredStructureBiomeKeys; private volatile ModdedDimensionMetadata.ConfiguredPack configuredPack; + private volatile ModdedDimensionMetadata.DimensionMetadata heightMetadata; + private volatile ServerLevel boundLevel; public IrisModdedChunkGenerator(BiomeSource biomeSource, String dimensionKey) { this(biomeSource, dimensionKey, new IrisModdedBiomeSource(biomeSource)); } private IrisModdedChunkGenerator(BiomeSource serializedBiomeSource, String dimensionKey, IrisModdedBiomeSource structureBiomeSource) { - super(structureBiomeSource); + this(serializedBiomeSource, dimensionKey, structureBiomeSource, + new ModdedImportedFeatureStage(structureBiomeSource)); + } + + private IrisModdedChunkGenerator(BiomeSource serializedBiomeSource, String dimensionKey, + IrisModdedBiomeSource structureBiomeSource, + ModdedImportedFeatureStage importedFeatures) { + // Two-argument ChunkGenerator constructor: the getter maps Iris custom-biome holders onto the + // generation settings of their vanilla derivative, which is what feeds the per-step feature lists and + // BiomeFilter's hasFeature gate. It is a pass-through to vanilla's default getter until a pack turns + // importedFeatures on, so with the control off nothing about generation changes. + super(structureBiomeSource, importedFeatures::generationSettings); + this.importedFeatures = importedFeatures; + importedFeatures.bind(this); this.dimensionKey = dimensionKey; this.serializedBiomeSource = serializedBiomeSource; this.structureBiomeSource = structureBiomeSource; @@ -177,18 +199,25 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } throw new IllegalStateException("Iris generator '" + dimensionKey + "' failed to replace its engine", error); } + this.boundLevel = level; this.activePack = pack; this.activeDimensionKey = packDimensionKey; this.seedOverride = seed; this.engine = replacement; this.configuredStructureBiomeKeys = null; this.configuredPack = null; + this.heightMetadata = engineHeights(replacement); this.engineBinding.reset(); this.engineBinding.complete(replacement); this.announced.set(false); this.structureBiomeSource.clearCaches(); + this.importedFeatures.invalidate(); this.nativeStructures.clearWorldCheckStructureShifts(); this.spawnTables.resetVanillaSpawnBiomes(); + // Bind time: a feature-order cycle in the new pack is reported here, once, and degrades to features-off. + // Never waits on a build owned by another thread: this method owns the generator monitor and the build + // path can need it. + this.importedFeatures.prepareWithoutWaiting(replacement); } public synchronized void unbindEngine() { @@ -207,11 +236,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { private void clearEngineBinding() { this.engine = null; + this.boundLevel = null; this.configuredStructureBiomeKeys = null; this.configuredPack = null; this.engineBinding.reset(); this.announced.set(false); this.structureBiomeSource.clearCaches(); + this.importedFeatures.invalidate(); this.nativeStructures.clearWorldCheckStructureShifts(); this.spawnTables.resetVanillaSpawnBiomes(); } @@ -221,13 +252,16 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { this.activeDimensionKey = packDimensionKey; this.seedOverride = seed; this.engine = null; + this.boundLevel = null; this.configuredStructureBiomeKeys = null; this.configuredPack = null; this.engineBinding.reset(); this.announced.set(false); this.structureBiomeSource.clearCaches(); + this.importedFeatures.invalidate(); this.nativeStructures.clearWorldCheckStructureShifts(); this.spawnTables.resetVanillaSpawnBiomes(); + primeHeightMetadata(); } public synchronized void resetToDefault() { @@ -276,12 +310,18 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } private ServerLevel boundLevel() { + ServerLevel cached = boundLevel; + if (cached != null) { + return cached; + } MinecraftServer server = ModdedEngineBootstrap.currentServer(); if (server == null) { return null; } - for (ServerLevel level : server.getAllLevels()) { + // Snapshot, never server.getAllLevels(): this runs off the server thread from data queries. + for (ServerLevel level : ModdedServerLevels.levels(server)) { if (level.getChunkSource().getGenerator() == this) { + boundLevel = level; return level; } } @@ -308,7 +348,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } requireCompletedShutdown(engine); unloading = false; - bindEngine(level); + Engine bound = bindEngine(level); + // Bind time: a feature-order cycle is reported here, once, and degrades to features-off. Non-waiting for + // the same reason as repointAndBind: this method owns the generator monitor. + importedFeatures.prepareWithoutWaiting(bound); + LOGGER.info("Iris bound {}: chunk system {}", level.dimension().identifier(), ModdedGenPool.describeChunkSystem()); } private Engine bindEngine(ServerLevel level) { @@ -320,6 +364,8 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { engineBinding.fail(error); throw error; } + // Cache the owning level so hot paths never scan the level map to find themselves. + boundLevel = level; Engine cached = engine; requireCompletedShutdown(cached); if (cached != null && !cached.isClosed() && cached.getComplex() != null) { @@ -342,6 +388,8 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { + "' created an engine without a ready biome complex"); } engine = created; + boundLevel = level; + heightMetadata = engineHeights(created); configuredStructureBiomeKeys = null; structureBiomeSource.clearCaches(); engineBinding.complete(created); @@ -376,9 +424,22 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { if (enabled) { return; } + String remedy = integratedEnvironment() + ? "enable 'Generate Structures' for this world; Iris requires it, and individual structures " + + "are denied through importedStructures.disabled" + : "set generate-structures=true in server.properties, restart the server, " + + "and deny individual structures through importedStructures.disabled"; throw new IllegalStateException("Iris generator '" + dimensionKey - + "' cannot bind while generate-structures=false; set generate-structures=true, restart the server, " - + "and deny individual structures through importedStructures.disabled"); + + "' cannot bind while generate-structures=false; " + remedy); + } + + private static boolean integratedEnvironment() { + try { + return ModdedEngineBootstrap.loader().clientEnvironment(); + } catch (Throwable e) { + // No loader bound (unit tests, very early boot): assume dedicated wording. + return false; + } } Engine engineOrNull() { @@ -490,6 +551,41 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } } + private static ModdedDimensionMetadata.DimensionMetadata engineHeights(Engine engine) { + IrisDimension dimension = engine.getDimension(); + int minY = engine.getMinHeight(); + return new ModdedDimensionMetadata.DimensionMetadata(minY, engine.getMaxHeight(), + dimension == null ? minY : minY + dimension.getFluidHeight()); + } + + /** + * Resolves the pack height metadata on the calling thread so the vanilla height accessors stay pure + * reads. Never fatal: a pack that cannot be read here falls back to {@link #UNBOUND_HEIGHTS} until a + * bind succeeds. + */ + private void primeHeightMetadata() { + try { + heightMetadata = configuredPack().metadata(); + } catch (Throwable e) { + LOGGER.warn("Iris generator '{}' could not pre-resolve pack heights for {}:{}: {}", + dimensionKey, activePack, activeDimensionKey, e.toString()); + } + } + + private ModdedDimensionMetadata.DimensionMetadata heightMetadata() { + ModdedDimensionMetadata.DimensionMetadata cached = heightMetadata; + if (cached != null) { + return cached; + } + ModdedDimensionMetadata.ConfiguredPack pack = configuredPack; + if (pack == null) { + return UNBOUND_HEIGHTS; + } + ModdedDimensionMetadata.DimensionMetadata resolved = pack.metadata(); + heightMetadata = resolved; + return resolved; + } + private ModdedDimensionMetadata.ConfiguredPack configuredPack() { ModdedDimensionMetadata.ConfiguredPack cached = configuredPack; if (cached != null) { @@ -510,6 +606,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { ModdedDimensionMetadata.ConfiguredPack resolved = new ModdedDimensionMetadata.ConfiguredPack( data, dimension, ModdedDimensionMetadata.dimensionMetadata(dimension)); configuredPack = resolved; + heightMetadata = resolved.metadata(); return resolved; } } @@ -518,6 +615,18 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { return dimensionKey; } + /** + * Operator-facing importedFeatures state: null when the control is off, "on" when the feature table is + * live, "degraded" when the control is enabled but the table failed to build (feature-order cycle). + */ + public String importedFeaturesStatus() { + Engine current = engineIfBound(); + if (current == null || !NativeFeatureGenerationPolicy.isEnabled(current)) { + return null; + } + return importedFeatures.active() ? "on" : "degraded"; + } + public Engine engineIfBound() { Engine current = engine; return unloading || current == null || current.isClosing() || current.isClosed() ? null : current; @@ -534,6 +643,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { public void onHotload() { configuredStructureBiomeKeys = null; structureBiomeSource.clearCaches(); + importedFeatures.invalidate(); nativeStructures.clearWorldCheckStructureShifts(); spawnTables.resetVanillaSpawnBiomes(); } @@ -711,9 +821,15 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, StructureManager structureManager) { Engine current = engine(); + // Self-heal for an engine bound through a data-query path instead of bindLevel; a no-op once prepared. + importedFeatures.prepare(current); try (GenerationSessionLease lease = requireGenerationLease(current, "modded_biome_decoration"); IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) { nativeStructures.placeVanillaStructures(level, chunk, structureManager); + // Vanilla's placed-feature pass, on THIS thread and never on ModdedGenPool: the FEATURES chunk + // step writes into the eight neighbouring chunks and is not parallel-safe. Inert unless the + // dimension set importedFeatures.enabled. + importedFeatures.run(level, chunk, current); } } @@ -771,7 +887,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { public int getGenDepth() { Engine current = engine; return current == null || current.isClosed() - ? configuredPack().metadata().depth() + ? heightMetadata().depth() : current.getMaxHeight() - current.getMinHeight(); } @@ -779,7 +895,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { public int getSeaLevel() { Engine current = engine; return current == null || current.isClosed() - ? configuredPack().metadata().seaLevel() + ? heightMetadata().seaLevel() : current.getMinHeight() + current.getDimension().getFluidHeight(); } @@ -787,7 +903,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { public int getMinY() { Engine current = engine; return current == null || current.isClosed() - ? configuredPack().metadata().minY() + ? heightMetadata().minY() : current.getMinHeight(); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java index fc91f41ad..1f07f0c31 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java @@ -25,13 +25,21 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.UUID; public final class MainWorldService { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final String MARKER_NAME = "mainworld.pending"; + private static final String PROPERTIES_NAME = "server.properties"; + /** + * Distinct exit status for the staged main-world restart, so a wrapper can tell it apart from a clean + * operator stop (0) and from a crash. 64 is the conventional first application-defined status. + */ + private static final int AUTO_RESTART_EXIT_STATUS = 64; private static final String[] VANILLA_DIMENSION_FOLDERS = { "region", "entities", @@ -62,23 +70,46 @@ public final class MainWorldService { if (pack == null || pack.isBlank()) { return; } - Path properties = instanceRoot().resolve("server.properties"); + Path instanceRoot = verifiedInstanceRoot("reconcile the Iris main world"); + if (instanceRoot == null) { + return; + } + Path properties = instanceRoot.resolve(PROPERTIES_NAME); String target = presetIdFor(pack); String currentType = readProperty(properties, "level-type"); if (!target.equals(currentType)) { writeLevelProperties(properties, target, config.mainWorldSeed()); markPending(); - LOGGER.warn("Iris main world '{}' staged: server.properties level-type set to {}. Restart again to generate it (this boot still uses the previous overworld; player data is kept).", pack, target); + LOGGER.warn("Iris main world '{}' staged: {} level-type set to {}. Restart again to generate it (this boot still uses the previous overworld; player data is kept).", + pack, properties, target); + if (config.mainWorldAutoRestart()) { + LOGGER.warn("Iris mainWorldAutoRestart is enabled; stopping the JVM now with exit status {} so a restart wrapper brings the server back on the new main world.", + AUTO_RESTART_EXIT_STATUS); + LOGGER.warn("Configure the start script to restart the server on exit status {} (status 0 means a clean stop, so it must not be reused for this).", + AUTO_RESTART_EXIT_STATUS); + System.exit(AUTO_RESTART_EXIT_STATUS); + } return; } if (!isPending()) { return; } - String levelName = firstNonBlank(readProperty(properties, "level-name"), "world"); - Path worldRoot = resolveWorldRoot(levelName); + Path worldRoot; + try { + worldRoot = resolveWorldRoot(instanceRoot, properties); + } catch (MissingWorldRootException missing) { + // First boot of a brand new instance, or a --universe/--world layout Iris cannot see from mod + // bootstrap: there is no prior overworld to move aside, so this is nothing to quarantine, not a + // reason to refuse startup. + clearPending(); + LOGGER.warn("Iris main world '{}' had nothing to quarantine: {} does not exist. Continuing boot; the overworld generates as {}.", + pack, missing.path(), target); + return; + } Path recovery = quarantineVanillaDimensions(worldRoot); clearPending(); - LOGGER.warn("Iris main world '{}' generated fresh: moved the prior overworld/nether/end data to {} so this boot regenerates them as {} (player data kept).", pack, recovery, target); + LOGGER.warn("Iris main world '{}' generated fresh: moved the prior overworld/nether/end data from {} to {} so this boot regenerates them as {} (player data kept).", + pack, worldRoot, recovery, target); } catch (Throwable e) { LOGGER.error("Iris main world reconciliation failed", e); throw new IllegalStateException( @@ -91,9 +122,12 @@ public final class MainWorldService { LOGGER.error("Iris main-world replacement is only available on dedicated servers; use the Create World generator selector in singleplayer"); return false; } + Path instanceRoot = verifiedInstanceRoot("stage the Iris main world"); + if (instanceRoot == null) { + return false; + } try { - Path properties = instanceRoot().resolve("server.properties"); - writeLevelProperties(properties, presetIdFor(packRef), seed); + writeLevelProperties(instanceRoot.resolve(PROPERTIES_NAME), presetIdFor(packRef), seed); markPending(); return true; } catch (IOException e) { @@ -110,8 +144,21 @@ public final class MainWorldService { } } - private static Path instanceRoot() { - return ModdedEngineBootstrap.loader().configDir().getParent(); + /** + * net.minecraft.server.Main reads server.properties as Paths.get("server.properties"), so the authoritative + * instance root is the JVM working directory - not configDir().getParent(), which points somewhere else + * entirely whenever the loader config tree is relocated (-Dfabric.configDir, a shared config mount, a + * launcher that starts the server from another directory). Refuse loudly rather than write or move files + * against a guessed root: every caller treats null as "not a dedicated instance we may touch". + */ + private static Path verifiedInstanceRoot(String operation) { + Path workingDirectory = Path.of("").toAbsolutePath().normalize(); + if (Files.isRegularFile(workingDirectory.resolve(PROPERTIES_NAME))) { + return workingDirectory; + } + LOGGER.error("Iris refuses to {}: no {} in the server working directory {}", operation, PROPERTIES_NAME, workingDirectory); + LOGGER.error("Iris only edits main-world properties in the directory the dedicated server reads {} from, and it moves no world data outside it. Start the server from its instance directory, or clear mainWorldPack in irisworldgen/modded.json.", PROPERTIES_NAME); + return null; } private static Path markerFile() { @@ -146,6 +193,11 @@ public final class MainWorldService { return null; } + /** + * Temp file plus ATOMIC_MOVE, the same publish shape ModdedForcedDatapack.writePublishedHash uses. A + * truncated server.properties bricks the next boot, and this write happens during bootstrap where a crash + * or a kill is entirely plausible. + */ private static void writeLevelProperties(Path properties, String target, long seed) throws IOException { List lines = Files.isRegularFile(properties) ? new ArrayList<>(Files.readAllLines(properties, StandardCharsets.UTF_8)) @@ -154,7 +206,14 @@ public final class MainWorldService { if (seed != 0L) { setProperty(lines, "level-seed", Long.toString(seed)); } - Files.write(properties, lines, StandardCharsets.UTF_8); + + Path temp = properties.resolveSibling(PROPERTIES_NAME + ".iris-tmp-" + UUID.randomUUID()); + Files.write(temp, lines, StandardCharsets.UTF_8); + try { + Files.move(temp, properties, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException atomicUnsupported) { + Files.move(temp, properties, StandardCopyOption.REPLACE_EXISTING); + } } private static void setProperty(List lines, String key, String value) { @@ -168,15 +227,97 @@ public final class MainWorldService { lines.add(prefix + value); } - private static Path resolveWorldRoot(String levelName) throws IOException { - Path root = instanceRoot().toAbsolutePath().normalize(); - Path worldRoot = root.resolve(levelName).toAbsolutePath().normalize(); - if (worldRoot.equals(root) || !worldRoot.startsWith(root)) { - throw new IOException("Unsafe level-name path outside the server instance: " + levelName); + /** + * Mirrors net.minecraft.server.Main world resolution: the universe root is --universe (default the working + * directory) and the world folder name is --world, falling back to the level-name property. + */ + private static Path resolveWorldRoot(Path instanceRoot, Path properties) throws IOException { + List arguments = processArguments(); + Path universe = universeRoot(instanceRoot, commandLineOption(arguments, "universe")); + String levelName = firstNonBlank(commandLineOption(arguments, "world"), + firstNonBlank(readProperty(properties, "level-name"), "world")); + return resolveWorldRoot(universe, levelName); + } + + static Path universeRoot(Path instanceRoot, String universeOption) { + return (universeOption == null || universeOption.isBlank() + ? instanceRoot + : instanceRoot.resolve(universeOption)).toAbsolutePath().normalize(); + } + + static Path resolveWorldRoot(Path universe, String levelName) throws IOException { + if (!Files.isDirectory(universe)) { + throw new MissingWorldRootException("Server universe directory does not exist: " + universe, universe); + } + Path worldRoot = universe.resolve(levelName).toAbsolutePath().normalize(); + if (worldRoot.equals(universe) || !worldRoot.startsWith(universe)) { + throw new IOException("Unsafe world name outside the server universe " + universe + ": " + levelName); + } + if (!Files.isDirectory(worldRoot)) { + throw new MissingWorldRootException("Server world directory does not exist: " + worldRoot, worldRoot); } return worldRoot; } + /** + * A universe or world directory that is simply absent. Separated from every other IO failure so + * reconciliation can treat it as nothing-to-quarantine instead of refusing startup; an unsafe world name + * stays a plain IOException and still refuses. + */ + static final class MissingWorldRootException extends IOException { + private static final long serialVersionUID = 1L; + + private final Path path; + + MissingWorldRootException(String message, Path path) { + super(message); + this.path = path; + } + + Path path() { + return path; + } + } + + /** + * Best effort read of a dedicated-server launch option. The parsed OptionSet is not reachable from mod + * bootstrap, so read the process arguments; when they are unavailable we fall back to the vanilla defaults, + * which is what the unpatched code assumed unconditionally. + */ + static String commandLineOption(List arguments, String name) { + String flag = "--" + name; + for (int index = 0; index < arguments.size(); index++) { + String argument = arguments.get(index); + if (argument.equals(flag)) { + return index + 1 < arguments.size() ? arguments.get(index + 1) : null; + } + if (argument.startsWith(flag + "=")) { + return argument.substring(flag.length() + 1); + } + } + return null; + } + + private static List processArguments() { + try { + Optional arguments = ProcessHandle.current().info().arguments(); + if (arguments.isPresent() && arguments.get().length > 0) { + return List.of(arguments.get()); + } + } catch (RuntimeException unavailable) { + LOGGER.debug("Iris could not read the process arguments", unavailable); + } + // Whitespace split only: sun.java.command is a flattened string with no quoting information, so a + // --universe or --world value containing spaces cannot be recovered from it. Deliberately not parsed + // further - a half-correct quote parser would hand world resolution a wrong directory, and the missing + // world root path already degrades to the vanilla defaults instead of failing the boot. + String command = System.getProperty("sun.java.command"); + if (command == null || command.isBlank()) { + return List.of(); + } + return List.of(command.trim().split("\\s+")); + } + private static Path quarantineVanillaDimensions(Path worldRoot) throws IOException { Path recovery = markerFile().getParent().resolve("mainworld-recovery-" + UUID.randomUUID()); List moved = new ArrayList<>(); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomeWriter.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomeWriter.java index ba7af1033..ed0914a2b 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomeWriter.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBiomeWriter.java @@ -34,13 +34,20 @@ import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; public final class ModdedBiomeWriter implements PlatformBiomeWriter { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final String VANILLA_FALLBACK_KEY = "minecraft:plains"; + private static final int MAX_CACHED_IDS = 4096; + /** NUL cannot occur in a pack or registry key, so the composite cache key stays unambiguous. */ + private static final char SCOPE_SEPARATOR = (char) 0; private final Supplier server; + private final AtomicBoolean serverMissingReported = new AtomicBoolean(); + private volatile RegistryCache cache; public ModdedBiomeWriter(Supplier server) { this.server = server; @@ -50,9 +57,33 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter { public int biomeIdFor(String key) { Registry registry = biomeRegistry(); if (registry == null) { + reportMissingServer("resolve the biome id for '" + key + "'", "using biome id 0"); return 0; } - int direct = idForKey(registry, scopedBiomeKey(key)); + if (key == null) { + LOGGER.warn("Iris biome writer got a null biome key; falling back to {}", VANILLA_FALLBACK_KEY); + return fallbackId(registry); + } + + RegistryCache cached = cacheFor(registry); + String scoped = scopedBiomeKey(key); + // The scoped key depends on the calling engine, so the same pack key can resolve differently per + // dimension. Cache on both halves; the derivative path below reads the raw key. + String cacheKey = scoped.equals(key) ? key : key + SCOPE_SEPARATOR + scoped; + Integer hit = cached.ids.get(cacheKey); + if (hit != null) { + return hit; + } + + int resolved = resolve(registry, key, scoped); + if (cached.ids.size() < MAX_CACHED_IDS) { + cached.ids.put(cacheKey, resolved); + } + return resolved; + } + + private int resolve(Registry registry, String key, String scoped) { + int direct = idForKey(registry, scoped); if (direct >= 0) { return direct; } @@ -79,17 +110,25 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter { @Override public List allBiomes() { Registry registry = biomeRegistry(); - List biomes = new ArrayList<>(); if (registry == null) { - return biomes; + reportMissingServer("enumerate the biome registry", "returning no biomes"); + return new ArrayList<>(); } - for (Identifier identifier : registry.keySet()) { - Biome biome = registry.getValue(identifier); - if (biome != null) { - biomes.add(ModdedBiome.of(biome, identifier.toString())); + + RegistryCache cached = cacheFor(registry); + List snapshot = cached.biomes; + if (snapshot == null) { + List built = new ArrayList<>(); + for (Identifier identifier : registry.keySet()) { + Biome biome = registry.getValue(identifier); + if (biome != null) { + built.add(ModdedBiome.of(biome, identifier.toString())); + } } + snapshot = List.copyOf(built); + cached.biomes = snapshot; } - return biomes; + return new ArrayList<>(snapshot); } private int idForKey(Registry registry, String key) { @@ -151,6 +190,44 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter { if (instance == null) { return null; } + // Read before write: this runs for every biome id on every generation thread, and an unconditional + // store on a shared cache line is a contended write on the hot path for a flag that is almost always + // already false. + if (serverMissingReported.get()) { + serverMissingReported.set(false); + } return instance.registryAccess().lookupOrThrow(Registries.BIOME); } + + /** + * The SPI requires biome writers to cache their registry lookups: biomeIdFor runs from generation threads + * for every biome a pack names, and the derivative path walks every active engine and every custom biome. + * The cache is keyed on the biome Registry instance, which the server replaces whenever datapacks reload, + * so a reload invalidates everything for free. + */ + private RegistryCache cacheFor(Registry registry) { + RegistryCache current = cache; + if (current != null && current.registry == registry) { + return current; + } + RegistryCache replacement = new RegistryCache(registry); + cache = replacement; + return replacement; + } + + private void reportMissingServer(String operation, String fallback) { + if (serverMissingReported.compareAndSet(false, true)) { + LOGGER.warn("Iris cannot {} before the Minecraft server is available; {}", operation, fallback); + } + } + + private static final class RegistryCache { + private final Registry registry; + private final ConcurrentHashMap ids = new ConcurrentHashMap<>(); + private volatile List biomes; + + private RegistryCache(Registry registry) { + this.registry = registry; + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java index 9a871eb03..4d344d94a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java @@ -68,6 +68,14 @@ public final class ModdedBlockBreakHandler { if (engineFor(level) == null) { return; } + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (scheduler == null) { + // finishPending is the only thing that evicts an unconsumed entry. With no scheduler there is no + // sweep, so an entry inserted here would leak for the rest of the server uptime. + LOGGER.debug("Iris skipped block-break provenance at {},{},{}: scheduler unavailable", + position.getX(), position.getY(), position.getZ()); + return; + } BreakKey key = new BreakKey(level, position.asLong()); ModdedTreeFellerService treeFeller = treeFellerService(); ModdedTreeFellerService.PreparedOrigin preparedOrigin = treeFeller == null @@ -75,10 +83,7 @@ public final class ModdedBlockBreakHandler { : treeFeller.prepare(level, player, position, brokenState); PendingBreak pending = new PendingBreak(brokenState, preparedOrigin); PENDING.put(key, pending); - ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); - if (scheduler != null) { - scheduler.laterGlobal(() -> finishPending(key, pending, position), 1); - } + scheduler.laterGlobal(() -> finishPending(key, pending, position), 1); } public static void clear() { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java index f455285c0..b8c96597c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java @@ -210,12 +210,9 @@ public final class ModdedBlockResolution { } static Parsed resolveGet(String bdxf) { - Parsed parsed = resolveOrNull(bdxf, false); - if (parsed != null) { - return parsed; - } - IrisLogging.error("Can't find block data for " + bdxf); - return new Parsed(AIR, null, null); + // Mirrors the Bukkit path: an unknown key warns (rate limited) and falls back to air, instead of + // resolving to air with no output at all. + return resolveNoCompat(bdxf); } static Parsed resolveNoCompat(String bdxf) { @@ -226,6 +223,10 @@ public final class ModdedBlockResolution { return new Parsed(AIR, null, null); } + /** + * Resolves a key, returning null when nothing claims it. Never substitutes air - {@link #resolveNoCompat(String)} + * owns the air fallback. + */ static Parsed resolveOrNull(String bdxf, boolean warn) { try { String bd = bdxf.trim(); @@ -248,7 +249,7 @@ public final class ModdedBlockResolution { if (warn) { warnUnresolved(bd, "Unknown Block Data '" + bd + "'"); } - return new Parsed(AIR, null, null); + return null; } return bdx; @@ -286,16 +287,35 @@ public final class ModdedBlockResolution { return parseStrict(s); } catch (IllegalArgumentException e) { if (s.contains("[")) { - return createBlockData(s.split("\\Q[\\E")[0], warn); + String base = s.split("\\Q[\\E")[0]; + Parsed stripped = createBlockData(base, warn); + if (stripped != null && warn) { + // Dedup on the base block key, not the full state string. UnresolvedKeyLog interns every key it + // is handed into a set it never trims, and a rejected property is usually rejected for every + // value and every combination a pack uses - keying on the state string would intern one entry per + // distinct state (16 levels x 6 facings x ...) for a single authoring mistake. + warnUnresolved("props:" + base, + "Block '" + base + "' rejected state '" + propertySection(s) + "'; using its default state"); + } + return stripped; } } if (warn) { - IrisLogging.warn("Can't find block data for " + s); + warnUnresolved(s, "Can't find block data for " + s); } return null; } + private static String propertySection(String key) { + int open = key.indexOf('['); + if (open < 0) { + return ""; + } + int close = key.indexOf(']', open); + return close < 0 ? key.substring(open + 1) : key.substring(open + 1, close); + } + private static Parsed materialBlockData(String ix) { if (ix.contains("[") || ix.contains(":")) { return null; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java index f06b1f63f..ad0a2f70c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java @@ -57,12 +57,15 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; public final class ModdedDimensionManager { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final Object LOCK = new Object(); private static final ConcurrentHashMap HANDLES = new ConcurrentHashMap<>(); - private static final TicketType TELEPORT_WARM_TICKET = new TicketType(TicketType.NO_TIMEOUT, TicketType.FLAG_LOADING); + private static final TicketType TELEPORT_WARM_TICKET = new TicketType(TicketType.NO_TIMEOUT, + TicketType.FLAG_LOADING | TicketType.FLAG_KEEP_DIMENSION_ACTIVE); + private static final long TELEPORT_WARM_TIMEOUT_SECONDS = 30L; private static volatile ModdedServerAccess access; private ModdedDimensionManager() { @@ -96,6 +99,8 @@ public final class ModdedDimensionManager { return handle.level(); } ResourceKey key = levelKey(dimensionId); + // Server thread only (create/remove hold LOCK, teleport and the primary-world router tick, command + // handlers). Off-thread callers must use ModdedServerLevels.level instead of the live map. for (ServerLevel level : server.getAllLevels()) { if (level.dimension().equals(key)) { return level; @@ -270,6 +275,8 @@ public final class ModdedDimensionManager { CompletableFuture .supplyAsync(() -> level.getChunkSource().addTicketAndLoadWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1), server) .thenCompose((CompletableFuture inner) -> inner) + // The ticket has no timeout of its own: bound the wait so the release below always runs. + .orTimeout(TELEPORT_WARM_TIMEOUT_SECONDS, TimeUnit.SECONDS) .whenComplete((Object result, Throwable error) -> server.execute(() -> { level.getChunkSource().removeTicketWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1); if (error != null) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java index 8c2689b9a..91c2cd579 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionRegistryStore.java @@ -37,10 +37,13 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public final class ModdedDimensionRegistryStore { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final String FILE_NAME = "iris-dimensions.json"; + private static final Pattern ID_FIELD = Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\""); private ModdedDimensionRegistryStore() { } @@ -53,6 +56,59 @@ public final class ModdedDimensionRegistryStore { return contents(file).dimensions(); } + /** + * Boot-safe load: a corrupt registry must not abort server start. The broken file is renamed aside so the + * next write starts clean, and every id we can still recognise in the raw text is reported as lost. + */ + public static List loadForStartup(MinecraftServer server) { + return loadForStartup(storeFile(server)); + } + + static List loadForStartup(Path file) { + try { + return load(file); + } catch (RuntimeException corrupt) { + LOGGER.error("Iris persistent dimension registry at {} is corrupt; quarantining it and continuing boot", + file, corrupt); + List lostIds = salvageIds(file); + if (lostIds.isEmpty()) { + LOGGER.error("Iris could not recover any dimension ids from the corrupt registry; re-create the worlds with /iris world create"); + } else { + LOGGER.error("Iris lost {} persistent dimension(s) from the corrupt registry: {}", + lostIds.size(), String.join(", ", lostIds)); + } + quarantine(file); + return List.of(); + } + } + + private static List salvageIds(Path file) { + List ids = new ArrayList<>(); + try { + Matcher matcher = ID_FIELD.matcher(Files.readString(file, StandardCharsets.UTF_8)); + while (matcher.find()) { + String id = matcher.group(1); + if (!ids.contains(id)) { + ids.add(id); + } + } + } catch (IOException | RuntimeException unreadable) { + LOGGER.warn("Iris could not scan the corrupt persistent dimension registry at {} for lost ids", file, unreadable); + } + return ids; + } + + private static void quarantine(Path file) { + Path broken = file.resolveSibling(FILE_NAME + ".broken-" + System.currentTimeMillis()); + try { + Files.move(file, broken, StandardCopyOption.REPLACE_EXISTING); + LOGGER.error("Iris moved the corrupt persistent dimension registry to {}", broken); + } catch (IOException failure) { + LOGGER.error("Iris could not quarantine the corrupt persistent dimension registry at {}; delete it by hand", + file, failure); + } + } + private static Contents contents(Path file) { if (!Files.isRegularFile(file)) { return new Contents(new ArrayList<>(), new ArrayList<>()); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index e81b38771..b796d73d7 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -125,6 +125,9 @@ public final class ModdedEngineBootstrap { } public static void serverStarted(MinecraftServer server) { + // Prime the off-thread level snapshot before anything can read it; the per-tick refresh in + // ModdedScheduler.tick has not run yet at this point. + ModdedServerLevels.refreshIfStale(server); bindWorldGenerators(server); ModdedStartup.runOnce(server); reconcileSpawn(server); @@ -174,6 +177,10 @@ public final class ModdedEngineBootstrap { ModdedScheduler scheduler = schedulerOrNull(); if (scheduler != null) { failure = runStopStage(failure, "scheduler", scheduler::shutdown); + } else { + // No bound runtime: the scheduler shutdown that normally drops the level snapshot never runs, and a + // static snapshot of a stopped server keeps its whole level graph alive. + failure = runStopStage(failure, "level snapshot", ModdedServerLevels::forget); } failure = runStopStage(failure, "generation pool", IrisModdedChunkGenerator::shutdownGenPool); failure = runStopStage(failure, "sentry", ModdedSentry::flush); @@ -184,8 +191,9 @@ public final class ModdedEngineBootstrap { initialSpawnWasDefault = false; }); if (failure != null) { + // The shutdown path must not propagate: propagating aborts the remaining loader stop handlers and + // can leave the level unsaved. Every stage already logged its own failure. LOGGER.error("Iris modded shutdown completed with failures", failure); - throw propagateStopFailure(failure); } } @@ -205,16 +213,6 @@ public final class ModdedEngineBootstrap { } } - private static RuntimeException propagateStopFailure(Throwable failure) { - if (failure instanceof RuntimeException runtimeException) { - return runtimeException; - } - if (failure instanceof Error fatalError) { - throw fatalError; - } - return new IllegalStateException("Iris modded shutdown completed with failures", failure); - } - private static void captureInitialSpawn(MinecraftServer server) { if (spawnCaptureServer == server) { return; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java index c95af51f0..2ee2bb8b4 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java @@ -44,12 +44,18 @@ import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -63,20 +69,79 @@ public final class ModdedForcedDatapack { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final String PACK_ID = "iris_worldgen"; private static final String PACK_FOLDER = "iris"; + private static final String HASH_FILE_NAME = "packs.hash"; + // Bump this whenever the emitted datapack content changes for reasons the pack-directory hash cannot see. + // v2: custom biomes now inherit their vanilla derivative's biome tags, so every already-published pack has + // to regenerate once. + private static final String HASH_SALT = "iris-forced-datapack-v2"; + private static final String GIT_DIRECTORY = ".git"; + private static final long PACKS_HASH_TTL_NANOS = 2_000_000_000L; private static final Object LOCK = new Object(); private static final AtomicBoolean LOADED = new AtomicBoolean(false); + private static final AtomicBoolean STALE_SERVE_LOGGED = new AtomicBoolean(false); + private static volatile PublishedState published; + private static volatile HashMemo packsHashMemo; private ModdedForcedDatapack() { } public static RepositorySource repositorySource() { return (Consumer consumer) -> { - Pack pack = buildPack(); + Pack pack = servePack(); consumer.accept(pack); LOADED.set(true); }; } + /** + * Serves the published datapack without regenerating it whenever the installed packs still hash to what + * was generated last. That fast path is lock-free; everything else takes LOCK and rechecks, so a boot-time + * daemon regeneration and a loadPacks regeneration can never stage concurrently (publishDirectory moves the + * live directory aside, which would break a concurrent read). + * + *

A hash mismatch is never served: the HASH_SALT bump alone mismatches every install on its first boot + * after an upgrade, and serving that directory hands Create World a pack without the current biome tags. + * A published pack whose hash cannot be computed at all is still served, with one warning. + */ + private static Pack servePack() { + String currentHash = packsHashOrEmpty(); + PublishedState current = publishedState(); + if (current != null && !currentHash.isEmpty() && current.packsHash().equals(currentHash)) { + try { + return requireReadablePack(current.directory()); + } catch (RuntimeException unreadable) { + published = null; + LOGGER.error("Iris could not read the published forced datapack at {}; regenerating", + current.directory(), unreadable); + } + } + synchronized (LOCK) { + String hash = packsHashOrEmpty(); + PublishedState state = publishedState(); + String reason; + if (state == null) { + reason = "no published pack"; + } else if (!hash.isEmpty() && !state.packsHash().equals(hash)) { + reason = "stale cache (hash changed)"; + } else { + if (hash.isEmpty() && STALE_SERVE_LOGGED.compareAndSet(false, true)) { + LOGGER.warn("Iris cannot hash the installed packs; serving the last generated forced datapack from {} unverified", + state.directory()); + } + try { + return requireReadablePack(state.directory()); + } catch (RuntimeException unreadable) { + published = null; + LOGGER.error("Iris could not read the published forced datapack at {}; regenerating", + state.directory(), unreadable); + } + reason = "unreadable published pack"; + } + LOGGER.info("Iris forced datapack cache is unusable ({}); generating it once now", reason); + return buildPack(); + } + } + public static void verifyInjected() { if (LOADED.get()) { return; @@ -105,11 +170,11 @@ public final class ModdedForcedDatapack { try { return requireReadablePack(regenerate()); } catch (RuntimeException | Error generationFailure) { - Path published = packDirectory(); - if (Files.isRegularFile(published.resolve("pack.mcmeta"))) { + Path lastKnownGood = packDirectory(); + if (Files.isRegularFile(lastKnownGood.resolve("pack.mcmeta"))) { LOGGER.error("Iris kept the last known-good generated datapack after regeneration failed", generationFailure); - return requireReadablePack(published); + return requireReadablePack(lastKnownGood); } throw generationFailure; } @@ -148,8 +213,50 @@ public final class ModdedForcedDatapack { } } + /** + * Regenerates only when the installed packs no longer hash to the published datapack. Used by the boot + * trigger so a steady-state restart does not pay the full staging cost. + */ + public static boolean regenerateIfStale(String reason) { + synchronized (LOCK) { + String currentHash = packsHashOrEmpty(); + PublishedState state = publishedState(); + if (state != null && !currentHash.isEmpty() && state.packsHash().equals(currentHash)) { + LOGGER.debug("Iris forced datapack is current ({}); skipping regeneration", reason); + return false; + } + LOGGER.info("Iris regenerating the forced datapack ({})", reason); + regenerate(); + return true; + } + } + + /** + * Off-thread regeneration trigger. Call sites that run on the server thread must use this so a command + * or lifecycle hook never blocks on pack staging. + */ + public static void scheduleRegeneration(String reason) { + Runnable task = () -> { + try { + regenerateIfStale(reason); + } catch (Throwable failure) { + LOGGER.error("Iris forced datapack regeneration failed ({})", reason, failure); + } + }; + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (scheduler != null) { + scheduler.async(task); + return; + } + Thread thread = new Thread(task, "iris-modded-datapack-regen"); + thread.setDaemon(true); + thread.start(); + } + private static Path write() throws IOException { - ModdedStartup.ensureDefaultPack(); + // No ensureDefaultPack() here: write() is reachable from loadPacks on the first boot, and loadPacks + // must never touch the network. ModdedStartup.prefetchDefaultPack covers the download at boot. + String packsHash = packsHash(); Path datapackRoot = datapackRoot(); Files.createDirectories(datapackRoot); Path stagingDirectory = Files.createTempDirectory(datapackRoot, PACK_FOLDER + ".staging-"); @@ -157,6 +264,12 @@ public final class ModdedForcedDatapack { writeStagedPack(stagingDirectory); requireReadablePack(stagingDirectory); publishDirectory(stagingDirectory, packDirectory()); + writePublishedHash(packsHash); + published = new PublishedState(packDirectory(), packsHash); + // Publish the hash this run was built from as the memo too: a memo captured before staging would + // otherwise mismatch what was just published and send the next serve straight back into buildPack. + packsHashMemo = new HashMemo(packsHash, System.nanoTime()); + STALE_SERVE_LOGGED.set(false); return packDirectory(); } catch (IOException | RuntimeException | Error failure) { try { @@ -168,6 +281,115 @@ public final class ModdedForcedDatapack { } } + private static PublishedState publishedState() { + PublishedState current = published; + if (current != null) { + return current; + } + Path directory = packDirectory(); + if (!Files.isRegularFile(directory.resolve("pack.mcmeta"))) { + return null; + } + PublishedState loaded = new PublishedState(directory, readPublishedHash()); + published = loaded; + return loaded; + } + + private static String readPublishedHash() { + Path hashFile = datapackRoot().resolve(HASH_FILE_NAME); + if (!Files.isRegularFile(hashFile)) { + return ""; + } + try { + return Files.readString(hashFile, StandardCharsets.UTF_8).trim(); + } catch (IOException unreadable) { + LOGGER.warn("Iris could not read the forced datapack hash at {}", hashFile, unreadable); + return ""; + } + } + + private static void writePublishedHash(String hash) throws IOException { + Path hashFile = datapackRoot().resolve(HASH_FILE_NAME); + Path temp = hashFile.resolveSibling(HASH_FILE_NAME + ".tmp-" + UUID.randomUUID()); + Files.writeString(temp, hash, StandardCharsets.UTF_8); + try { + Files.move(temp, hashFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException atomicUnsupported) { + Files.move(temp, hashFile, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** + * Short-TTL memo: loadPacks runs on every PackRepository reload and the hash walks every installed pack + * file (thousands on a studio install), so back-to-back reloads must not re-walk the tree. The window is + * small enough that an operator dropping in a pack still gets picked up on the next reload. + */ + private static String packsHashOrEmpty() { + long now = System.nanoTime(); + HashMemo memo = packsHashMemo; + if (memo != null && now - memo.takenAtNanos() < PACKS_HASH_TTL_NANOS) { + return memo.hash(); + } + String hash; + try { + hash = packsHash(); + } catch (IOException | RuntimeException failure) { + LOGGER.warn("Iris could not hash the installed packs directory", failure); + hash = ""; + } + packsHashMemo = new HashMemo(hash, now); + return hash; + } + + /** + * Content hash over the installed packs: relative path, size and mtime of every regular file, plus the + * pack format and loader the generated datapack is shaped for. + */ + private static String packsHash() throws IOException { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException missing) { + throw new IllegalStateException("SHA-256 is unavailable", missing); + } + digest.update((HASH_SALT + '|' + ModdedEngineBootstrap.loader().platformName() + + '|' + DataVersion.getLatest().getPackFormat() + '\n').getBytes(StandardCharsets.UTF_8)); + Path root = packsRoot(); + if (Files.isDirectory(root)) { + List entries = new ArrayList<>(); + Files.walkFileTree(root, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) { + // Studio packs can be git checkouts; .git churns constantly and never reaches the datapack. + return GIT_DIRECTORY.equals(directory.getFileName().toString()) + ? FileVisitResult.SKIP_SUBTREE + : FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) { + if (attributes.isRegularFile()) { + entries.add(root.relativize(file).toString().replace('\\', '/') + + '|' + attributes.size() + '|' + attributes.lastModifiedTime().toMillis()); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException failure) { + entries.add(root.relativize(file).toString().replace('\\', '/') + "|unreadable"); + return FileVisitResult.CONTINUE; + } + }); + entries.sort(Comparator.naturalOrder()); + for (String entry : entries) { + digest.update(entry.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) '\n'); + } + } + return HexFormat.of().formatHex(digest.digest()); + } + private static void writeStagedPack(Path stagingDirectory) throws IOException { Map> seenBiomes = new LinkedHashMap<>(); IDataFixer fixer = DataVersion.getLatest().get(); @@ -189,6 +411,9 @@ public final class ModdedForcedDatapack { } writePackMeta(stagingDirectory); + // Forge only: FML has no block-drops event Iris can use, so drops are routed through a global loot + // modifier. NeoForge does not need one - IrisNeoForgeBootstrap listens to BlockDropsEvent and both + // replaces and appends drops there, so emitting a modifier would double-apply them. if ("forge".equalsIgnoreCase(ModdedEngineBootstrap.loader().platformName())) { writeForgeBlockLootModifier(stagingDirectory); } @@ -211,9 +436,7 @@ public final class ModdedForcedDatapack { } catch (Throwable validationFailure) { LOGGER.error("Iris excluded pack '{}' from Create World because validation failed", sourcePack.getName(), validationFailure); - if (validationFailure instanceof Error fatalError) { - throw fatalError; - } + rethrowIfUnrecoverable(validationFailure); return false; } if (!validation.isLoadable()) { @@ -235,9 +458,7 @@ public final class ModdedForcedDatapack { } catch (Throwable installationFailure) { LOGGER.error("Iris excluded pack '{}' from Create World because datapack serialization failed", sourcePack.getName(), installationFailure); - if (installationFailure instanceof Error fatalError) { - throw fatalError; - } + rethrowIfUnrecoverable(installationFailure); installed = false; } @@ -259,6 +480,20 @@ public final class ModdedForcedDatapack { } } + /** + * Per-pack staging isolates every failure so one broken pack cannot brick Create World for all of them. + * Only a VM-level failure (heap exhausted, native stack blown) is rethrown; LinkageError and + * ExceptionInInitializerError are exactly the per-pack failures that must stay contained. + */ + private static void rethrowIfUnrecoverable(Throwable failure) { + if (failure instanceof OutOfMemoryError outOfMemory) { + throw outOfMemory; + } + if (failure instanceof StackOverflowError stackOverflow) { + throw stackOverflow; + } + } + private static void mergeDirectory(Path sourceDirectory, Path destinationDirectory) throws IOException { List entries = new ArrayList<>(); try (Stream walk = Files.walk(sourceDirectory)) { @@ -345,15 +580,6 @@ public final class ModdedForcedDatapack { .resolve("worldgen").resolve("world_preset").resolve(presetPath + ".json"); Files.createDirectories(output.getParent()); Files.writeString(output, json, StandardCharsets.UTF_8); - String legacyPresetKey = dimensionKey.equals(packName) - ? packName - : packName + "_" + dimensionKey; - Path legacyOutput = datapackRoot.toPath().resolve("data").resolve("irisworldgen") - .resolve("worldgen").resolve("world_preset").resolve(legacyPresetKey + ".json"); - if (!Files.exists(legacyOutput)) { - Files.createDirectories(legacyOutput.getParent()); - Files.writeString(legacyOutput, json, StandardCharsets.UTF_8); - } } } @@ -426,6 +652,9 @@ public final class ModdedForcedDatapack { .resolve("dimension_type").resolve(typePath + ".json"); Files.createDirectories(output.getParent()); Files.writeString(output, json, StandardCharsets.UTF_8); + // Load-bearing, not dead: worlds created before the scoped pack path reference + // irisworldgen: in their level.dat, and ModdedWorldEngines accepts that legacy + // key when validating the runtime dimension contract. Removing this emission unloads those worlds. Path legacyOutput = datapackRoot.toPath().resolve("data").resolve("irisworldgen") .resolve("dimension_type").resolve(dimension.getDimensionTypeKey() + ".json"); if (!Files.exists(legacyOutput)) { @@ -517,4 +746,10 @@ public final class ModdedForcedDatapack { private static Path packsRoot() { return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs"); } + + private record PublishedState(Path directory, String packsHash) { + } + + private record HashMemo(String hash, long takenAtNanos) { + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenPool.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenPool.java index aeb93a4d5..c8d612ed3 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenPool.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenPool.java @@ -18,59 +18,329 @@ package art.arcane.iris.modded; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.concurrent.ExecutorService; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Owns the Iris generation pool and the single decision of whether this loader already runs chunk + * generation on its own worker threads. + * + *

The parallel-chunk-system probe is a two-stage check: {@link Class#forName} presence gates it + * (never a version string), then the mod's own configuration is read reflectively to confirm the + * feature is actually enabled. Any reflection failure resolves to "not parallel", which keeps + * generation on the Iris pool - the safe side, since an extra hop only costs throughput while a + * missing hop serializes generation on the loader's chunk threads. + */ +public final class ModdedGenPool { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final long SHUTDOWN_DRAIN_MILLIS = 2_000L; + private static final String[] C2ME_MARKERS = { + "com.ishland.c2me.base.ModProperties", + "com.ishland.c2me.base.common.config.C2MEConfig", + "com.ishland.c2me.opts.chunkio.ModProperties" + }; + private static final String C2ME_CONFIG = "com.ishland.c2me.base.common.config.C2MEConfig"; + private static final String[] C2ME_SECTIONS = {"asyncScheduling", "asyncSchedulingConfig", "threadedWorldGen"}; + private static final String[] C2ME_FLAGS = {"enabled", "isEnabled", "shouldEnable"}; + private static final String MOONRISE_MARKER = "ca.spottedleaf.moonrise.common.util.MoonriseCommon"; + private static final String[] MOONRISE_WORKER_COUNTS = {"getWorkerThreads", "workerThreads"}; + private static final String[] MOONRISE_POOLS = {"WORKER_POOL", "workerPool"}; + private static final String[] MOONRISE_POOL_COUNTS = {"getCoreThreads", "getThreadCount", "getThreads", "coreThreads", "threadCount"}; -final class ModdedGenPool { private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger(); - private static final boolean PARALLEL_CHUNK_SYSTEM = detectParallelChunkSystem(); - private static volatile ExecutorService genPool = createGenPool(); + private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem(); + private static final AtomicReference GEN_POOL = new AtomicReference<>(createGenPool()); private ModdedGenPool() { } - static boolean parallelChunkSystem() { - return PARALLEL_CHUNK_SYSTEM; + /** + * True when the loader's chunk system already generates off the server thread, so Iris must not + * add its own pool hop. + */ + public static boolean parallelChunkSystem() { + return CHUNK_SYSTEM.parallel(); + } + + /** + * Terse description of the detected chunk system, for one-line diagnostics. + */ + public static String describeChunkSystem() { + return CHUNK_SYSTEM.description(); } static ExecutorService pool() { - return genPool; + ExecutorService pool = GEN_POOL.get(); + if (pool != null && !pool.isShutdown()) { + return pool; + } + start(); + ExecutorService restarted = GEN_POOL.get(); + if (restarted == null) { + throw new RejectedExecutionException("Iris gen pool is shut down"); + } + return restarted; } static void start() { - ExecutorService pool = genPool; - if (pool == null || pool.isShutdown()) { - genPool = createGenPool(); + while (true) { + ExecutorService current = GEN_POOL.get(); + if (current != null && !current.isShutdown()) { + return; + } + ExecutorService created = createGenPool(); + if (GEN_POOL.compareAndSet(current, created)) { + return; + } + created.shutdownNow(); } } static void shutdown() { - ExecutorService pool = genPool; - if (pool != null) { - pool.shutdownNow(); + ExecutorService pool = GEN_POOL.getAndSet(null); + if (pool == null) { + return; + } + pool.shutdown(); + try { + if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) { + return; + } + LOGGER.debug("Iris gen pool did not drain in {}ms, forcing shutdown", SHUTDOWN_DRAIN_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + pool.shutdownNow(); + } + + private static ChunkSystem detectChunkSystem() { + ChunkSystem detected = probeC2ME(); + if (detected == null) { + detected = probeMoonrise(); + } + if (detected == null) { + detected = new ChunkSystem(false, "vanilla"); + } + LOGGER.info("Iris chunk system: {} (parallel={}, generation on {})", + detected.description(), + detected.parallel() ? "yes" : "no", + detected.parallel() ? "loader threads" : "Iris gen pool"); + return detected; + } + + private static ChunkSystem probeC2ME() { + if (!anyPresent(C2ME_MARKERS)) { + return null; + } + Class config = loadOrNull(C2ME_CONFIG); + if (config == null) { + return new ChunkSystem(false, "c2me present, config class missing"); + } + Boolean enabled = readSectionFlag(config, C2ME_SECTIONS, C2ME_FLAGS); + if (enabled == null) { + return new ChunkSystem(false, "c2me present, async scheduling unreadable"); + } + return new ChunkSystem(enabled, enabled ? "c2me async scheduling on" : "c2me async scheduling off"); + } + + private static ChunkSystem probeMoonrise() { + Class marker = loadOrNull(MOONRISE_MARKER); + if (marker == null) { + return null; + } + Integer workers = readMoonriseWorkers(marker); + if (workers == null) { + return new ChunkSystem(false, "moonrise present, worker pool unreadable"); + } + if (workers <= 0) { + return new ChunkSystem(false, "moonrise worker pool empty"); + } + return new ChunkSystem(true, "moonrise workers=" + workers); + } + + /** + * Reads {@code .

.} where the section may itself be the boolean. + * Returns null when nothing along the path is readable. + */ + private static Boolean readSectionFlag(Class configClass, String[] sections, String[] flags) { + for (String section : sections) { + Field field = staticFieldOrNull(configClass, section); + if (field == null) { + continue; + } + Object value; + try { + value = field.get(null); + } catch (Throwable e) { + LOGGER.debug("Iris chunk system probe could not read {}.{}: {}", configClass.getName(), section, e.toString()); + continue; + } + if (value instanceof Boolean flag) { + return flag; + } + if (value == null) { + continue; + } + Boolean nested = readBooleanMember(value, flags); + if (nested != null) { + return nested; + } + } + return null; + } + + private static Boolean readBooleanMember(Object owner, String[] names) { + for (String name : names) { + for (Class type = owner.getClass(); type != null && type != Object.class; type = type.getSuperclass()) { + Field field = declaredFieldOrNull(type, name); + if (field != null && (field.getType() == boolean.class || field.getType() == Boolean.class)) { + try { + if (field.get(owner) instanceof Boolean flag) { + return flag; + } + } catch (Throwable e) { + LOGGER.debug("Iris chunk system probe could not read {}.{}: {}", type.getName(), name, e.toString()); + } + } + Method method = declaredMethodOrNull(type, name); + if (method != null && (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class)) { + try { + if (method.invoke(owner) instanceof Boolean flag) { + return flag; + } + } catch (Throwable e) { + LOGGER.debug("Iris chunk system probe could not call {}.{}(): {}", type.getName(), name, e.toString()); + } + } + } + } + return null; + } + + private static Integer readMoonriseWorkers(Class marker) { + for (String name : MOONRISE_WORKER_COUNTS) { + Integer direct = readIntMember(marker, null, name); + if (direct != null) { + return direct; + } + Field field = staticFieldOrNull(marker, name); + if (field != null) { + try { + Object value = field.get(null); + if (value instanceof Number number) { + return number.intValue(); + } + } catch (Throwable e) { + LOGGER.debug("Iris chunk system probe could not read {}.{}: {}", marker.getName(), name, e.toString()); + } + } + } + for (String poolName : MOONRISE_POOLS) { + Field field = staticFieldOrNull(marker, poolName); + if (field == null) { + continue; + } + Object pool; + try { + pool = field.get(null); + } catch (Throwable e) { + LOGGER.debug("Iris chunk system probe could not read {}.{}: {}", marker.getName(), poolName, e.toString()); + continue; + } + if (pool == null) { + continue; + } + for (String countName : MOONRISE_POOL_COUNTS) { + Integer count = readIntMember(pool.getClass(), pool, countName); + if (count != null) { + return count; + } + } + } + return null; + } + + private static Integer readIntMember(Class type, Object owner, String name) { + for (Class current = type; current != null && current != Object.class; current = current.getSuperclass()) { + Method method = declaredMethodOrNull(current, name); + if (method == null) { + continue; + } + try { + if (method.invoke(owner) instanceof Number number) { + return number.intValue(); + } + } catch (Throwable e) { + LOGGER.debug("Iris chunk system probe could not call {}.{}(): {}", current.getName(), name, e.toString()); + } + } + return null; + } + + private static Field staticFieldOrNull(Class type, String name) { + for (Class current = type; current != null && current != Object.class; current = current.getSuperclass()) { + Field field = declaredFieldOrNull(current, name); + if (field != null) { + return field; + } + } + return null; + } + + private static Field declaredFieldOrNull(Class type, String name) { + try { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + return field; + } catch (NoSuchFieldException e) { + return null; + } catch (Throwable e) { + LOGGER.debug("Iris chunk system probe could not access field {}.{}: {}", type.getName(), name, e.toString()); + return null; } } - private static boolean detectParallelChunkSystem() { - String[] markers = { - "com.ishland.c2me.base.ModProperties", - "com.ishland.c2me.base.common.config.C2MEConfig", - "com.ishland.c2me.opts.chunkio.ModProperties", - "ca.spottedleaf.moonrise.common.util.MoonriseCommon" - }; + private static Method declaredMethodOrNull(Class type, String name) { + try { + Method method = type.getDeclaredMethod(name); + method.setAccessible(true); + return method; + } catch (NoSuchMethodException e) { + return null; + } catch (Throwable e) { + LOGGER.debug("Iris chunk system probe could not access method {}.{}(): {}", type.getName(), name, e.toString()); + return null; + } + } + + private static boolean anyPresent(String[] markers) { for (String marker : markers) { - try { - Class.forName(marker, false, IrisModdedChunkGenerator.class.getClassLoader()); + if (loadOrNull(marker) != null) { return true; - } catch (Throwable ignored) { } } return false; } + private static Class loadOrNull(String name) { + try { + return Class.forName(name, false, ModdedGenPool.class.getClassLoader()); + } catch (Throwable e) { + LOGGER.debug("Iris chunk system probe: {} absent ({})", name, e.getClass().getSimpleName()); + return null; + } + } + private static ExecutorService createGenPool() { int threads = Math.max(2, Runtime.getRuntime().availableProcessors()); ThreadPoolExecutor pool = new ThreadPoolExecutor( @@ -84,4 +354,7 @@ final class ModdedGenPool { pool.allowCoreThreadTimeOut(true); return pool; } + + private record ChunkSystem(boolean parallel, String description) { + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java new file mode 100644 index 000000000..b75edfd28 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedImportedFeatureStage.java @@ -0,0 +1,435 @@ +/* + * 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 . + */ + +package art.arcane.iris.modded; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.GenerationSessionLease; +import art.arcane.iris.engine.framework.NativeFeatureGenerationPolicy; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisDecorationStep; +import art.arcane.iris.engine.object.IrisImportedFeatureControl; +import art.arcane.iris.util.project.context.IrisContext; +import it.unimi.dsi.fastutil.ints.IntArraySet; +import it.unimi.dsi.fastutil.ints.IntSet; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.Registry; +import net.minecraft.core.SectionPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeGenerationSettings; +import net.minecraft.world.level.biome.FeatureSorter; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.levelgen.RandomSupport; +import net.minecraft.world.level.levelgen.WorldgenRandom; +import net.minecraft.world.level.levelgen.XoroshiroRandomSource; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Native placed-feature passthrough for one Iris dimension, gated on {@code importedFeatures.enabled}. + * + *

This runs the FEATURES half of vanilla's decoration pass and nothing else. Iris places native structures + * itself, with its own vertical fitting and vegetation clearing, so calling {@code super.applyBiomeDecoration} + * would place every structure a second time. The feature half is reproduced here off the same decoration and + * feature seeds vanilla derives, so an imported feature lands where vanilla would have put it. + * + *

Threading: {@link #run} runs on the worldgen thread that is generating the chunk, never on + * {@link ModdedGenPool}. The FEATURES chunk step is not parallel-safe - it writes into the eight neighbouring + * chunks through {@code WorldGenLevel}, and vanilla and every threaded chunk system serialize it. Terrain is + * the only Iris step that may fan out. + * + *

Everything here is inert while the control is disabled: no table is built, no registry is walked, and + * {@link #generationSettings} answers exactly what vanilla's default getter answers. + */ +final class ModdedImportedFeatureStage { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final String CYCLE_MARKER = "Feature order cycle found"; + private static final long NO_GENERATION = Long.MIN_VALUE; + + private final IrisModdedBiomeSource biomeSource; + private final ReentrantLock buildLock = new ReentrantLock(); + private volatile IrisModdedChunkGenerator generator; + private volatile FeatureTable featureTable; + private volatile long inertGeneration = NO_GENERATION; + + ModdedImportedFeatureStage(IrisModdedBiomeSource biomeSource) { + this.biomeSource = biomeSource; + } + + void bind(IrisModdedChunkGenerator generator) { + this.generator = generator; + } + + /** + * Drops the feature table. Called from every repoint, hotload and unbind path so a table built for one + * pack can never serve another. + */ + void invalidate() { + featureTable = null; + inertGeneration = NO_GENERATION; + } + + boolean active() { + return featureTable != null; + } + + /** + * The generation-settings getter handed to {@code ChunkGenerator}'s two-argument constructor. Maps an Iris + * custom biome holder onto the generation settings of the vanilla biome its Iris biome derives from, so + * the per-step feature lists and {@code BiomeFilter}'s hasFeature gate both see real features for a biome + * whose datapack JSON declares none by design. Real registry biomes pass straight through. + * + *

With {@code importedFeatures} disabled there is no table and this is vanilla's default getter. + */ + BiomeGenerationSettings generationSettings(Holder biome) { + FeatureTable table = featureTable; + if (table == null) { + return biome.value().getGenerationSettings(); + } + return settingsFor(biome, table.derivatives()); + } + + /** + * Chunk-path prepare. The volatile fast path is unlocked, so a prepared stage costs two reads per chunk; + * the build itself is serialized because {@code applyBiomeDecoration} calls this from every worldgen + * thread, and two threads that both found the stage unprepared would each run {@code FeatureSorter}, whose + * cycle detection is the expensive part. Waiting here is safe: this caller holds no generator monitor. + */ + void prepare(Engine engine) { + prepare(engine, true); + } + + /** + * Bind and repoint prepare, which is what makes a feature-order cycle a single bind-time ERROR instead of + * a chunk-generation crash. Those callers hold the generator monitor and the build path can need it (the + * biome source may bind an engine while resolving), so this one never waits for another thread's build: it + * builds now or leaves it to the next chunk's prepare. + */ + void prepareWithoutWaiting(Engine engine) { + prepare(engine, false); + } + + private void prepare(Engine engine, boolean waitForBuild) { + if (engine == null || engine.isClosed() || engine.isClosing()) { + return; + } + long generation = biomeSource.packGeneration(); + if (settled(generation)) { + return; + } + if (waitForBuild) { + buildLock.lock(); + } else if (!buildLock.tryLock()) { + return; + } + try { + if (settled(generation)) { + return; + } + build(engine, generation); + } finally { + buildLock.unlock(); + } + } + + private boolean settled(long generation) { + FeatureTable current = featureTable; + if (current != null && current.generation() == generation) { + return true; + } + return inertGeneration == generation; + } + + private void build(Engine engine, long generation) { + IrisImportedFeatureControl control; + try { + control = NativeFeatureGenerationPolicy.control(engine); + } catch (RuntimeException error) { + LOGGER.error("Iris could not read importedFeatures for this dimension; features off: {}", + error.toString()); + markInert(generation); + return; + } + if (!control.shouldGenerateFeatures()) { + markInert(generation); + return; + } + FeatureTable built; + try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_imported_features"); + IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + built = buildTable(engine, control, generation); + } catch (Throwable error) { + LOGGER.error("Iris importedFeatures is off for {}: feature table construction failed: {}", + dimensionKey(engine), error.toString()); + markInert(generation); + return; + } + if (built == null) { + markInert(generation); + return; + } + featureTable = built; + inertGeneration = NO_GENERATION; + // Arm the worldcheck log watch here, before any chunk decorates: arming from the first pass instead + // missed every far-chunk write the first chunk made. No-op unless -Diris.worldcheck is set. + WorldCheckFeaturePlacement.arm(); + LOGGER.info("Iris importedFeatures on for {}: {} biomes, {} steps, {} custom-biome derivative maps", + dimensionKey(engine), built.biomes().size(), built.steps().size(), + built.derivatives().size()); + } + + private void markInert(long generation) { + featureTable = null; + inertGeneration = generation; + } + + private FeatureTable buildTable(Engine engine, IrisImportedFeatureControl control, long generation) { + // Registry-ordered biome list. FeatureSorter's cycle detection walks it, so an unordered list makes + // detection depend on JVM hash order and turns a real cycle into an intermittent one. + List> biomes = biomeSource.orderedPossibleBiomes(); + if (biomes.isEmpty()) { + LOGGER.error("Iris importedFeatures is on but {} exposes no biomes; features off", + dimensionKey(engine)); + return null; + } + Map> byKey = new HashMap<>(biomes.size()); + for (Holder biome : biomes) { + String key = holderKey(biome); + if (key != null) { + byKey.put(key, biome); + } + } + Map> derivatives = customBiomeDerivatives(engine, byKey); + List steps; + try { + // Same inputs as vanilla's own memo, built here so it can be keyed on the Iris pack generation and + // so the cycle failure lands at bind time. + steps = FeatureSorter.buildFeaturesPerStep(biomes, + (Holder biome) -> settingsFor(biome, derivatives).features(), true); + } catch (IllegalStateException error) { + String message = error.getMessage(); + if (message == null || !message.contains(CYCLE_MARKER)) { + throw error; + } + LOGGER.error("Iris importedFeatures is off for {}: the registered placed features cannot be ordered." + + " {}. Remove or reorder the conflicting content, or leave" + + " importedFeatures.enabled false.", + dimensionKey(engine), message); + return null; + } + boolean filtered = control.getDisabled() != null && !control.getDisabled().isEmpty(); + return new FeatureTable(generation, control, List.copyOf(biomes), Set.copyOf(biomes), + Map.copyOf(byKey), steps, Map.copyOf(derivatives), filtered); + } + + /** + * Maps every generated Iris custom biome key onto the registry holder of its Iris biome's vanilla + * derivative. The custom biome's own datapack JSON carries no features by design; this is the only place + * the vanilla feature set enters. + */ + private Map> customBiomeDerivatives(Engine engine, Map> byKey) { + Map> derivatives = new HashMap<>(); + for (IrisBiome irisBiome : engine.getAllBiomes()) { + if (!irisBiome.isCustom()) { + continue; + } + String derivativeKey = normalizeKey(irisBiome.getVanillaDerivativeKey()); + // Resolve from the registry, not only from this source's own biome set: a sea or shore biome's + // structure derivative is rewritten away from its vanilla derivative, so the derivative whose + // features we want is not always a biome this source can emit. + Holder derivative = byKey.get(derivativeKey); + if (derivative == null) { + derivative = biomeSource.registeredBiome(derivativeKey); + } + if (derivative == null) { + LOGGER.warn("Iris importedFeatures: vanilla derivative {} of biome {} is not registered;" + + " its custom biomes generate no imported features", + derivativeKey, irisBiome.getLoadKey()); + continue; + } + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + derivatives.put(ModdedWorldgenIds.biomeRef(engine, customBiome.getId()), derivative); + } + } + return derivatives; + } + + private static BiomeGenerationSettings settingsFor(Holder biome, + Map> derivatives) { + Holder mapped = derivatives.get(holderKey(biome)); + return mapped == null + ? biome.value().getGenerationSettings() + : mapped.value().getGenerationSettings(); + } + + /** + * Runs the vanilla placed-feature pass for one chunk, on the calling worldgen thread. A no-op while the + * control is disabled or the table degraded. + */ + void run(WorldGenLevel level, ChunkAccess chunk, Engine engine) { + FeatureTable table = featureTable; + if (table == null) { + WorldCheckFeaturePlacement.recordFeaturesOff(); + return; + } + if (table.generation() != biomeSource.packGeneration()) { + // A repoint landed between the prepare above and this chunk. Refuse stale content outright; the + // next chunk's prepare rebuilds against the new pack. + invalidate(); + return; + } + IrisModdedChunkGenerator owner = generator; + if (owner == null) { + return; + } + ChunkPos centerPos = chunk.getPos(); + SectionPos sectionPos = SectionPos.of(centerPos, level.getMinSectionY()); + BlockPos origin = sectionPos.origin(); + Registry featureRegistry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE); + List steps = table.steps(); + WorldgenRandom random = new WorldgenRandom(new XoroshiroRandomSource(RandomSupport.generateUniqueSeed())); + long decorationSeed = random.setDecorationSeed(level.getSeed(), origin.getX(), origin.getZ()); + Set> chunkBiomes = chunkBiomes(level, sectionPos, table); + + try { + for (int stepIndex = 0; stepIndex < steps.size(); stepIndex++) { + if (!table.control().shouldGenerateStep(IrisDecorationStep.byOrdinal(stepIndex))) { + continue; + } + placeStep(level, table, steps.get(stepIndex), featureRegistry, chunkBiomes, owner, + random, decorationSeed, origin, stepIndex); + } + } catch (Throwable error) { + WorldCheckFeaturePlacement.recordPlacementFailure(centerPos, error); + throw new IllegalStateException("Iris imported feature placement failed for chunk " + + centerPos.x() + "," + centerPos.z(), error); + } finally { + level.setCurrentlyGenerating(null); + } + WorldCheckFeaturePlacement.recordPlacementPass(); + } + + private void placeStep(WorldGenLevel level, FeatureTable table, FeatureSorter.StepFeatureData stepData, + Registry featureRegistry, Set> chunkBiomes, + IrisModdedChunkGenerator owner, WorldgenRandom random, long decorationSeed, + BlockPos origin, int stepIndex) { + IntSet stepFeatures = new IntArraySet(); + for (Holder biome : chunkBiomes) { + List> biomeFeatures = settingsFor(biome, table.derivatives()).features(); + if (stepIndex >= biomeFeatures.size()) { + continue; + } + for (Holder feature : biomeFeatures.get(stepIndex)) { + stepFeatures.add(stepData.indexMapping().applyAsInt(feature.value())); + } + } + if (stepFeatures.isEmpty()) { + return; + } + // Sorted global indices: identical ordering to vanilla, and each feature's seed comes from its own + // global index, so denying one feature never shifts another. + int[] featureIndices = stepFeatures.toIntArray(); + Arrays.sort(featureIndices); + for (int globalIndex : featureIndices) { + PlacedFeature feature = stepData.features().get(globalIndex); + if (table.filtered()) { + Identifier featureId = featureRegistry.getKey(feature); + if (featureId != null && !table.control().shouldGenerate(featureId.toString())) { + continue; + } + } + random.setFeatureSeed(decorationSeed, globalIndex, stepIndex); + level.setCurrentlyGenerating(() -> describeFeature(featureRegistry, feature)); + feature.placeWithBiomeCheck(level, owner, random, origin); + } + } + + private static String describeFeature(Registry registry, PlacedFeature feature) { + Identifier id = registry.getKey(feature); + return id == null ? feature.toString() : id.toString(); + } + + /** + * Biomes actually present in the 3x3 chunk neighbourhood, intersected with the dimension's biome set. Same + * shape as vanilla, which drops any section biome its biome source does not claim. Holders are canonicalised + * back onto the table's own holders so the index mapping cannot be handed a holder it never saw. + */ + private Set> chunkBiomes(WorldGenLevel level, SectionPos sectionPos, FeatureTable table) { + List> collected = new ArrayList<>(); + ChunkPos.rangeClosed(sectionPos.chunk(), 1).forEach((ChunkPos chunkPos) -> { + ChunkAccess neighbour = level.getChunk(chunkPos.x(), chunkPos.z()); + for (LevelChunkSection section : neighbour.getSections()) { + section.getBiomes().getAll(collected::add); + } + }); + Set> present = new LinkedHashSet<>(); + for (Holder biome : collected) { + if (table.biomeSet().contains(biome)) { + present.add(biome); + continue; + } + String key = holderKey(biome); + Holder canonical = key == null ? null : table.byKey().get(key); + if (canonical != null) { + present.add(canonical); + } + } + return present; + } + + private static String dimensionKey(Engine engine) { + return engine.getDimension() == null ? "" : engine.getDimension().getLoadKey(); + } + + private static String holderKey(Holder holder) { + return holder.unwrapKey() + .map(key -> key.identifier().toString().toLowerCase(Locale.ROOT)) + .orElse(null); + } + + private static String normalizeKey(String key) { + Identifier identifier = key == null ? null : Identifier.tryParse(key); + return identifier == null ? null : identifier.toString().toLowerCase(Locale.ROOT); + } + + private record FeatureTable(long generation, IrisImportedFeatureControl control, + List> biomes, Set> biomeSet, + Map> byKey, + List steps, + Map> derivatives, boolean filtered) { + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedItemTranslator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedItemTranslator.java index 1ac1308ef..79149a98f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedItemTranslator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedItemTranslator.java @@ -59,7 +59,6 @@ import net.minecraft.world.item.component.TooltipDisplay; import net.minecraft.world.item.enchantment.Enchantment; import net.minecraft.world.item.enchantment.ItemEnchantments; -import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -69,8 +68,6 @@ import java.util.concurrent.ConcurrentHashMap; public final class ModdedItemTranslator { private static final Set WARNED = ConcurrentHashMap.newKeySet(); - private static final Field TYPE_FIELD = lootField("type"); - private static final Field DYE_COLOR_FIELD = lootField("dyeColor"); private static final String COLOR_CODES = "0123456789AaBbCcDdEeFfKkLlMmNnOoRrXx"; private ModdedItemTranslator() { @@ -140,7 +137,7 @@ public final class ModdedItemTranslator { } private static ItemStack baseStack(IrisLoot loot, RNG rng) { - String raw = readString(TYPE_FIELD, loot); + String raw = loot.getTypeKey(); if (raw == null || raw.isBlank()) { return null; } @@ -187,7 +184,7 @@ public final class ModdedItemTranslator { } } - String dye = readString(DYE_COLOR_FIELD, loot); + String dye = loot.getDyeColorKey(); if (dye != null) { applyDyeColor(stack, dye); } @@ -214,7 +211,9 @@ public final class ModdedItemTranslator { if (name == null || name.isBlank()) { continue; } - String key = name.toLowerCase(Locale.ROOT); + // Same normalization as IrisEnchantment.resolve() on Bukkit: trim, lowercase, spaces to underscores. + // Without it 'Fire Aspect' resolves on Bukkit and warns as unknown here, for the same pack. + String key = name.trim().toLowerCase(Locale.ROOT).replace(' ', '_'); Identifier id = Identifier.tryParse(key.contains(":") ? key : "minecraft:" + key); Optional> holder = id == null ? Optional.empty() : registry.get(id); if (holder.isEmpty()) { @@ -405,22 +404,4 @@ public final class ModdedItemTranslator { } } - private static Field lootField(String name) { - try { - Field field = IrisLoot.class.getDeclaredField(name); - field.setAccessible(true); - return field; - } catch (NoSuchFieldException e) { - throw new IllegalStateException("IrisLoot field missing: " + name, e); - } - } - - private static String readString(Field field, IrisLoot loot) { - try { - Object value = field.get(loot); - return value == null ? null : value.toString(); - } catch (IllegalAccessException e) { - return null; - } - } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java new file mode 100644 index 000000000..c86bc8d0d --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinAudit.java @@ -0,0 +1,133 @@ +/* + * 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 . + */ + +package art.arcane.iris.modded; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; + +/** + * Boot audit for the Iris mixin configs. Mixin registration is per-loader (fabric.mod.json entry, NeoForge + * mods.toml [[mixins]] block, Forge shadowJar MixinConfigs manifest attribute) and a config that never gets + * registered fails silently: no crash, the hooks simply never exist, and entity persistence, custom mob loot + * and the Iris world-type labels quietly stop working. + * + *

Application is checked structurally: Mixin transfers an {@code @Inject} handler into the target class, + * so the handler's presence on the target proves the mixin applied. Mixin 0.8.7 renames the transferred + * method to {@code handler$$}, so the declared name is matched as an exact name or as a + * {@code $}-prefixed suffix. Client targets are resolved by name so this class stays free of client + * references and is safe on a dedicated server. + */ +public final class ModdedMixinAudit { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final AtomicBoolean AUDITED = new AtomicBoolean(false); + + private static final List EXPECTED = List.of( + new ExpectedMixin("EntityPersistenceMixin", "entity", + "net.minecraft.world.entity.Entity", "iris$applyGeneratedPersistence", + false, ModdedMixinFlags::entityPersistenceRan), + new ExpectedMixin("LivingEntityLootMixin", "entity", + "net.minecraft.world.entity.LivingEntity", "iris$replaceBaseLoot", + false, ModdedMixinFlags::livingEntityLootRan), + new ExpectedMixin("MobAwarenessMixin", "entity", + "net.minecraft.world.entity.Mob", "iris$tickUnawareMob", + false, ModdedMixinFlags::mobAwarenessRan), + new ExpectedMixin("IrisWorldOpenFlowsMixin", "client", + "net.minecraft.client.gui.screens.worldselection.WorldOpenFlows", + "iris$openWorldCheckWorldStemCompatibility", + true, ModdedMixinFlags::worldOpenFlowsRan), + new ExpectedMixin("IrisWorldTypeEntryMixin", "client", + "net.minecraft.client.gui.screens.worldselection.WorldCreationUiState$WorldTypeEntry", + "iris$describePreset", + true, ModdedMixinFlags::worldTypeEntryRan)); + + private ModdedMixinAudit() { + } + + public static void runOnce() { + if (!AUDITED.compareAndSet(false, true)) { + return; + } + audit(ModdedEngineBootstrap.loader().platformName(), + ModdedEngineBootstrap.loader().clientEnvironment()); + } + + static void reset() { + AUDITED.set(false); + } + + static void audit(String platform, boolean clientEnvironment) { + List missing = new ArrayList<>(); + List applied = new ArrayList<>(); + for (ExpectedMixin expected : EXPECTED) { + if (expected.clientOnly() && !clientEnvironment) { + continue; + } + if (isApplied(expected)) { + applied.add(expected.mixinName() + (expected.ran().getAsBoolean() ? "" : " (not yet exercised)")); + } else { + missing.add(expected.config() + '/' + expected.mixinName() + " -> " + expected.targetClass()); + } + } + if (missing.isEmpty()) { + LOGGER.info("Iris mixin audit ok on {} ({} dist): {}", platform, + clientEnvironment ? "client" : "server", String.join(", ", applied)); + return; + } + LOGGER.error("==============================================================="); + LOGGER.error("Iris mixin audit FAILED on {} ({} dist): {} of {} expected mixin(s) were not applied.", + platform, clientEnvironment ? "client" : "server", missing.size(), + missing.size() + applied.size()); + for (String entry : missing) { + LOGGER.error(" missing: {}", entry); + } + LOGGER.error("The mixin config was not registered for this loader (fabric.mod.json mixins, neoforge.mods.toml [[mixins]], forge MixinConfigs manifest attribute)."); + LOGGER.error("Entity persistence, custom mob loot and Iris world-type labels are disabled until this is fixed."); + LOGGER.error("==============================================================="); + } + + private static boolean isApplied(ExpectedMixin expected) { + try { + Class target = Class.forName(expected.targetClass(), false, + ModdedMixinAudit.class.getClassLoader()); + for (Method method : target.getDeclaredMethods()) { + // Mixin 0.8.7 renames applied @Inject handlers to handler$$, so an exact + // name match alone reports every applied mixin as missing. + String name = method.getName(); + if (name.equals(expected.handlerMethod()) + || name.endsWith('$' + expected.handlerMethod())) { + return true; + } + } + return false; + } catch (ClassNotFoundException | LinkageError unavailable) { + LOGGER.warn("Iris mixin audit could not inspect {}", expected.targetClass(), unavailable); + return true; + } + } + + private record ExpectedMixin(String mixinName, String config, String targetClass, String handlerMethod, + boolean clientOnly, BooleanSupplier ran) { + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinFlags.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinFlags.java new file mode 100644 index 000000000..264ac316f --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedMixinFlags.java @@ -0,0 +1,100 @@ +/* + * 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 . + */ + +package art.arcane.iris.modded; + +/** + * Plain flag holder every Iris mixin marks from its injected body. It is deliberately free of any + * net.minecraft, client or loader reference so the client-dist mixins in art.arcane.iris.client.mixin can + * write to it without dragging client types into modded-common. + * + *

These flags say "the injected code ran at least once", which is weaker than "the mixin was applied": + * a hook only fires when its target path executes. {@link ModdedMixinAudit} is what proves application at + * boot; these are the runtime confirmation reported alongside it. + */ +public final class ModdedMixinFlags { + private static volatile boolean entityPersistenceRan; + private static volatile boolean livingEntityLootRan; + private static volatile boolean mobAwarenessRan; + private static volatile boolean worldOpenFlowsRan; + private static volatile boolean worldTypeEntryRan; + + private ModdedMixinFlags() { + } + + // Read before write on every marker: the entity hooks run per entity save and per mob tick, and a + // volatile store is a cache-line invalidation on every core that reads the flag. The flags are one-way, + // so the guarded write costs one plain-ish read once set and races only ever re-store the same value. + public static void markEntityPersistence() { + if (!entityPersistenceRan) { + entityPersistenceRan = true; + } + } + + public static void markLivingEntityLoot() { + if (!livingEntityLootRan) { + livingEntityLootRan = true; + } + } + + public static void markMobAwareness() { + if (!mobAwarenessRan) { + mobAwarenessRan = true; + } + } + + public static void markWorldOpenFlows() { + if (!worldOpenFlowsRan) { + worldOpenFlowsRan = true; + } + } + + public static void markWorldTypeEntry() { + if (!worldTypeEntryRan) { + worldTypeEntryRan = true; + } + } + + public static boolean entityPersistenceRan() { + return entityPersistenceRan; + } + + public static boolean livingEntityLootRan() { + return livingEntityLootRan; + } + + public static boolean mobAwarenessRan() { + return mobAwarenessRan; + } + + public static boolean worldOpenFlowsRan() { + return worldOpenFlowsRan; + } + + public static boolean worldTypeEntryRan() { + return worldTypeEntryRan; + } + + static void reset() { + entityPersistenceRan = false; + livingEntityLootRan = false; + mobAwarenessRan = false; + worldOpenFlowsRan = false; + worldTypeEntryRan = false; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java index b22a4b4b4..54e67458f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedNativeStructureStage.java @@ -210,8 +210,10 @@ final class ModdedNativeStructureStage { ChunkPos disabledChunk = chunk.getPos(); throw new IllegalStateException("Iris cannot generate native structures in chunk " + disabledChunk.x() + "," + disabledChunk.z() - + " because generate-structures=false disables them outside the pack; set generate-structures=true, " - + "restart the server, and deny individual structures through importedStructures.disabled"); + + " because generate-structures=false disables them outside the pack. That flag is fixed when " + + "the world is created (server.properties generate-structures, or the Generate Structures " + + "toggle in singleplayer), so it cannot be changed for this world: create a new world with " + + "structures enabled, and deny individual structures through importedStructures.disabled"); } ChunkPos chunkPos = chunk.getPos(); SectionPos sectionPos = SectionPos.of(chunkPos, world.getMinSectionY()); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java index 9be37a66e..41c406fd9 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPackInstaller.java @@ -56,18 +56,26 @@ public final class ModdedPackInstaller { synchronized (installLock) { File packs = configDir.resolve("irisworldgen").resolve("packs").toFile(); try { - if (PackDownloader.isDefaultOverworld(pack)) { - return PackDownloader.downloadDefaultOverworld( - packs, forceOverwrite, feedback) != null; + boolean installed = PackDownloader.isDefaultOverworld(pack) + ? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback) != null + : PackDownloader.download( + packs, + "IrisDimensions/" + pack, + branch, + forceOverwrite, + false, + feedback) != null; + if (installed) { + // Pack-install completion is one of the four forced-datapack regeneration triggers; every + // install call site already runs off the server thread, so regenerate inline here. A + // regeneration failure must never turn a successful install into a failed one. + try { + ModdedForcedDatapack.regenerateIfStale("pack install " + pack); + } catch (Throwable regenerationFailure) { + LOGGER.error("Iris installed pack '{}' but could not regenerate the forced datapack", pack, regenerationFailure); + } } - return PackDownloader.download( - packs, - "IrisDimensions/" + pack, - branch, - forceOverwrite, - false, - feedback - ) != null; + return installed; } catch (IOException error) { LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error); feedback.accept(IrisLanguage.plain( diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java index 15584a3c1..88a3db5dd 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatform.java @@ -35,9 +35,17 @@ import net.minecraft.world.entity.EntitySpawnReason; import net.minecraft.world.entity.EntityType; import java.io.File; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; public final class ModdedPlatform implements IrisPlatform { + private static final int ERROR_SIGNATURE_BURST = 5; + private static final int ERROR_SIGNATURE_CAPACITY = 256; + private static final long ERROR_SUMMARY_INTERVAL_MILLIS = 300_000L; + private static final ConcurrentHashMap ERROR_THROTTLES = new ConcurrentHashMap<>(); + private static volatile Consumer ERROR_SINK = null; private static volatile Consumer CAPTURE_SINK = null; @@ -166,11 +174,19 @@ public final class ModdedPlatform implements IrisPlatform { ModdedIrisLog.info(message); } + /** + * Throttled per exception signature. A single broken pack rule can fail on every generated chunk, and this + * feeds Sentry, so each distinct signature reports its first few occurrences and then only a periodic + * suppressed-count summary. + */ @Override public void reportError(Throwable error) { if (error == null) { return; } + if (!allowErrorReport(error)) { + return; + } Consumer sink = ERROR_SINK; if (sink != null) { sink.accept(error); @@ -187,6 +203,77 @@ public final class ModdedPlatform implements IrisPlatform { } } + static void resetErrorThrottles() { + ERROR_THROTTLES.clear(); + } + + private static boolean allowErrorReport(Throwable error) { + String signature = errorSignature(error); + ErrorThrottle throttle = ERROR_THROTTLES.get(signature); + if (throttle == null) { + if (ERROR_THROTTLES.size() >= ERROR_SIGNATURE_CAPACITY) { + evictLeastRecentlySeen(); + } + throttle = ERROR_THROTTLES.computeIfAbsent(signature, ignored -> new ErrorThrottle()); + } + return throttle.allow(signature); + } + + /** + * At the cap a new signature used to report through unthrottled for the rest of the uptime, which is the + * failure mode the cap exists to prevent: one broken pack rule firing on every chunk with a per-chunk line + * number produces new signatures forever. Evict the entry nothing has hit for the longest instead. O(n) on + * an error path with n=256, and an evicted signature simply earns a fresh burst if it comes back. + */ + private static void evictLeastRecentlySeen() { + String oldest = null; + long oldestSeenAt = Long.MAX_VALUE; + for (Map.Entry entry : ERROR_THROTTLES.entrySet()) { + long seenAt = entry.getValue().lastSeenAt(); + if (seenAt < oldestSeenAt) { + oldestSeenAt = seenAt; + oldest = entry.getKey(); + } + } + if (oldest != null) { + ERROR_THROTTLES.remove(oldest); + } + } + + static String errorSignature(Throwable error) { + StackTraceElement[] trace = error.getStackTrace(); + String frame = trace.length == 0 + ? "" + : trace[0].getClassName() + '.' + trace[0].getMethodName() + ':' + trace[0].getLineNumber(); + return error.getClass().getName() + '@' + frame; + } + + private static final class ErrorThrottle { + private final AtomicLong reported = new AtomicLong(); + private final AtomicLong suppressed = new AtomicLong(); + private final AtomicLong nextSummaryAt = new AtomicLong(); + private final AtomicLong lastSeenAt = new AtomicLong(System.currentTimeMillis()); + + private long lastSeenAt() { + return lastSeenAt.get(); + } + + private boolean allow(String signature) { + long now = System.currentTimeMillis(); + lastSeenAt.set(now); + if (reported.get() < ERROR_SIGNATURE_BURST) { + reported.incrementAndGet(); + return true; + } + long total = suppressed.incrementAndGet(); + long due = nextSummaryAt.get(); + if (now >= due && nextSummaryAt.compareAndSet(due, now + ERROR_SUMMARY_INTERVAL_MILLIS)) { + ModdedIrisLog.warn("Iris suppressed " + total + " repeats of " + signature); + } + return false; + } + } + private static int parseVersion(String raw) { if (raw == null || raw.isBlank()) { return -1; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java index 6f0ff028d..3d73650a2 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPrimaryWorldRouter.java @@ -44,6 +44,16 @@ public final class ModdedPrimaryWorldRouter { routed.clear(); } + /** + * Drops a disconnected player's routing mark. Without this the set grows with every unique player the + * server has ever seen, and a returning player is never routed again. + */ + public static void forget(UUID player) { + if (player != null) { + routed.remove(player); + } + } + public static void tick(MinecraftServer server) { if (server == null) { return; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java index 7a1a4a489..017a0f52d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedProtocolHandler.java @@ -34,6 +34,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Objects; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; public final class ModdedProtocolHandler { @@ -43,11 +44,13 @@ public final class ModdedProtocolHandler { private static final ConcurrentHashMap SESSION_ENGINES = new ConcurrentHashMap<>(); private static final ConcurrentHashMap SESSION_LEVELS = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap SESSION_IDS = new ConcurrentHashMap<>(); private static volatile ModdedProtocolChannel channel; private static volatile IrisSessionRegistry registry; private static volatile IrisProtocolServer protocolServer; private static volatile ModdedProtocolTransport transport; + private static volatile IrisVisionRequestService visionRequests; private static int dimensionSyncTicks; private ModdedProtocolHandler() { @@ -64,6 +67,7 @@ public final class ModdedProtocolHandler { } SESSION_ENGINES.clear(); SESSION_LEVELS.clear(); + SESSION_IDS.clear(); dimensionSyncTicks = 0; IrisSessionRegistry sessionRegistry = new IrisSessionRegistry(); ModdedProtocolTransport serverTransport = new ModdedProtocolTransport(server, boundChannel); @@ -73,53 +77,85 @@ public final class ModdedProtocolHandler { return engine == null || engine.isClosed() ? null : engine; }; protocol.setEngineResolver(engineResolver); - protocol.setVisionTileHandler(IrisVisionRequestService.create(engineResolver, sessionRegistry)); + IrisVisionRequestService visionService = IrisVisionRequestService.create(engineResolver, sessionRegistry); + protocol.setVisionTileHandler(visionService); registry = sessionRegistry; transport = serverTransport; protocolServer = protocol; + visionRequests = visionService; IrisServices.register(IrisProtocolServer.class, protocol); if (server.getPlayerList() == null) { return; } for (ServerPlayer player : server.getPlayerList().getPlayers()) { - sessionRegistry.register(new IrisSession(player.getUUID().toString(), serverTransport)); + sessionRegistry.register(new IrisSession(sessionId(player), serverTransport)); } } public static void stop() { IrisServices.remove(IrisProtocolServer.class); IrisSessionRegistry current = registry; + IrisVisionRequestService vision = visionRequests; if (current != null) { for (IrisSession session : current.all()) { current.unregister(session.id()); + if (vision != null) { + vision.clearSession(session.id()); + } } } SESSION_ENGINES.clear(); SESSION_LEVELS.clear(); + SESSION_IDS.clear(); dimensionSyncTicks = 0; registry = null; protocolServer = null; transport = null; + visionRequests = null; } public static void onPlayerJoin(ServerPlayer player) { - IrisSessionRegistry current = registry; - ModdedProtocolTransport currentTransport = transport; - if (player == null || current == null || currentTransport == null) { + if (player == null) { return; } - current.register(new IrisSession(player.getUUID().toString(), currentTransport)); + String sessionId = sessionId(player); + ModdedStartup.warnPackFailuresTo(player); + IrisSessionRegistry current = registry; + ModdedProtocolTransport currentTransport = transport; + if (current == null || currentTransport == null) { + return; + } + current.register(new IrisSession(sessionId, currentTransport)); } public static void onPlayerDisconnect(ServerPlayer player) { - IrisSessionRegistry current = registry; - if (player == null || current == null) { + if (player == null) { return; } - String sessionId = player.getUUID().toString(); - current.unregister(sessionId); + UUID id = player.getUUID(); + String sessionId = SESSION_IDS.remove(id); + if (sessionId == null) { + sessionId = id.toString(); + } + ModdedPrimaryWorldRouter.forget(id); SESSION_ENGINES.remove(sessionId); SESSION_LEVELS.remove(sessionId); + IrisSessionRegistry current = registry; + if (current != null) { + current.unregister(sessionId); + } + IrisVisionRequestService vision = visionRequests; + if (vision != null) { + vision.clearSession(sessionId); + } + } + + /** + * UUID.toString allocates a 36-char string every call; the dimension sync tick runs over every player four + * times a second, so the id is interned per player on first use and dropped on disconnect. + */ + private static String sessionId(ServerPlayer player) { + return SESSION_IDS.computeIfAbsent(player.getUUID(), UUID::toString); } public static void onInbound(ServerPlayer player, byte[] frame) { @@ -127,7 +163,7 @@ public final class ModdedProtocolHandler { if (player == null || frame == null || current == null) { return; } - String sessionId = player.getUUID().toString(); + String sessionId = sessionId(player); ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); if (scheduler == null) { current.onClientFrame(sessionId, frame); @@ -148,7 +184,7 @@ public final class ModdedProtocolHandler { } dimensionSyncTicks = 0; for (ServerPlayer player : server.getPlayerList().getPlayers()) { - String sessionId = player.getUUID().toString(); + String sessionId = sessionId(player); IrisSession session = current.get(sessionId); if (session == null || !session.isReady()) { continue; @@ -167,6 +203,8 @@ public final class ModdedProtocolHandler { private static boolean syncDimension(IrisProtocolServer protocol, String sessionId, ServerLevel level, String levelId) { ChunkGenerator generator = level.getChunkSource().getGenerator(); + // engineIfBound only, never commandEngine: the sync tick runs on the server thread and constructing an + // engine there stalls the tick for the whole pack load. An unbound generator simply retries next tick. Engine engine = generator instanceof IrisModdedChunkGenerator irisGenerator ? resolveEngine(level, irisGenerator) : null; if (generator instanceof IrisModdedChunkGenerator && engine == null) { return false; @@ -185,7 +223,8 @@ public final class ModdedProtocolHandler { private static Engine resolveEngine(ServerLevel level, IrisModdedChunkGenerator generator) { try { - return generator.commandEngine(); + Engine engine = generator.engineIfBound(); + return engine == null || engine.isClosed() ? null : engine; } catch (Throwable failure) { LOGGER.error("Iris dimension status engine lookup failed for {}", level.dimension().identifier(), failure); return null; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java index e81ca9583..a4fc38929 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRegistries.java @@ -18,12 +18,16 @@ package art.arcane.iris.modded; +import art.arcane.iris.modded.api.ModdedCustomContentRegistry; +import art.arcane.iris.modded.api.ModdedDataType; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockProperty; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformEntityType; import art.arcane.iris.spi.PlatformItem; import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.data.UnresolvedKeyLog; import net.minecraft.core.Registry; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; @@ -45,6 +49,8 @@ import java.util.Map; import java.util.function.Supplier; public final class ModdedRegistries implements PlatformRegistries { + private static final UnresolvedKeyLog NOT_READY = new UnresolvedKeyLog("Iris modded registry reads before server ready", 60_000L); + private final Supplier server; public ModdedRegistries(Supplier server) { @@ -126,6 +132,7 @@ public final class ModdedRegistries implements PlatformRegistries { List keys = new ArrayList<>(); Registry registry = biomeRegistry(); if (registry == null) { + warnNotReady("biome"); return keys; } for (Identifier identifier : registry.keySet()) { @@ -139,6 +146,7 @@ public final class ModdedRegistries implements PlatformRegistries { List keys = new ArrayList<>(); MinecraftServer instance = server.get(); if (instance == null) { + warnNotReady("structure"); return keys; } for (Identifier identifier : instance.registryAccess().lookupOrThrow(Registries.STRUCTURE).keySet()) { @@ -153,9 +161,15 @@ public final class ModdedRegistries implements PlatformRegistries { for (Identifier identifier : BuiltInRegistries.ITEM.keySet()) { keys.add(identifier.toString()); } + keys.addAll(ModdedCustomContentRegistry.providerKeys(ModdedDataType.ITEM)); return keys; } + @Override + public List specialEntityKeys() { + return ModdedCustomContentRegistry.providerKeys(ModdedDataType.ENTITY); + } + @Override public List entityKeys() { List keys = new ArrayList<>(); @@ -171,6 +185,7 @@ public final class ModdedRegistries implements PlatformRegistries { for (Identifier identifier : BuiltInRegistries.BLOCK.keySet()) { keys.add(identifier.toString()); } + keys.addAll(customBlockKeys()); return keys; } @@ -179,6 +194,7 @@ public final class ModdedRegistries implements PlatformRegistries { List keys = new ArrayList<>(); Registry registry = enchantmentRegistry(); if (registry == null) { + warnNotReady("enchantment"); return keys; } for (Identifier identifier : registry.keySet()) { @@ -199,17 +215,50 @@ public final class ModdedRegistries implements PlatformRegistries { @Override public Map> blockStateProperties() { Map> properties = new LinkedHashMap<>(); + // One List instance per identical property group. SchemaBuilder groups consecutive entries by list + // identity, so sharing collapses the emitted schema instead of writing a block-state object per block. + Map> shared = new LinkedHashMap<>(); for (Block block : BuiltInRegistries.BLOCK) { BlockState defaultState = block.defaultBlockState(); List converted = new ArrayList<>(); for (Property property : block.getStateDefinition().getProperties()) { converted.add(convertProperty(property, defaultState)); } - properties.put(BuiltInRegistries.BLOCK.getKey(block).toString(), List.copyOf(converted)); + List group = shared.computeIfAbsent(groupSignature(converted), key -> List.copyOf(converted)); + properties.put(BuiltInRegistries.BLOCK.getKey(block).toString(), group); + } + List none = shared.computeIfAbsent(groupSignature(List.of()), key -> List.of()); + for (String key : customBlockKeys()) { + properties.putIfAbsent(key, none); } return properties; } + private static String groupSignature(List group) { + StringBuilder signature = new StringBuilder(group.size() * 24); + for (PlatformBlockProperty property : group) { + signature.append(property.name()).append(':').append(property.jsonType()).append('=') + .append(property.defaultValue()).append(property.allowedValues()).append(';'); + } + return signature.toString(); + } + + private static List customBlockKeys() { + List keys = new ArrayList<>(ModdedCustomContentRegistry.aliasBlockKeys()); + keys.addAll(ModdedCustomContentRegistry.providerKeys(ModdedDataType.BLOCK)); + return keys; + } + + private static void warnNotReady(String registryName) { + if (NOT_READY.firstOccurrence(registryName)) { + IrisLogging.warn("Iris registry read for '" + registryName + "' before the server is ready; returning empty"); + } + String summary = NOT_READY.pollSummary(); + if (summary != null) { + IrisLogging.warn(summary); + } + } + private Registry biomeRegistry() { MinecraftServer instance = server.get(); if (instance == null) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRuntimeRegistry.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRuntimeRegistry.java index de43d065f..f998a3680 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRuntimeRegistry.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedRuntimeRegistry.java @@ -47,7 +47,9 @@ public final class ModdedRuntimeRegistry { return; } throw new IllegalStateException("Iris dimension type '" + typeRef - + "' is not synchronized. Restart after installing the pack before creating its world."); + + "' is not registered. Minecraft freezes the dimension-type registry before Iris can add it," + + " so a pack installed after boot only takes effect on the next start." + + restartAdvice()); } static void ensureCustomBiomes(RegistryAccess registryAccess, IrisDimension dimension, String pack) { @@ -78,8 +80,26 @@ public final class ModdedRuntimeRegistry { } if (!missing.isEmpty()) { throw new IllegalStateException("Iris pack '" + pack + "' has " + missing.size() - + " unsynchronized custom biome(s). Restart before creating its world. First missing entry: " + + " custom biome(s) that are not registered. Pack '" + pack + + "' was installed after boot, and Minecraft freezes the biome registry before Iris can add them." + + restartAdvice() + " First missing entry: " + missing.getFirst()); } } + + /** + * One complete instruction per environment. Singleplayer has no /iris world create step to follow the + * restart, so the client branch must not be suffixed with one. + */ + private static String restartAdvice() { + boolean client; + try { + client = ModdedEngineBootstrap.loader().clientEnvironment(); + } catch (RuntimeException unbound) { + client = false; + } + return client + ? " Quit to the title screen and re-create the world from the Iris world type." + : " Restart the server, then run /iris world create again."; + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java index e9df43a9b..34ad09ab6 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedScheduler.java @@ -24,52 +24,78 @@ import net.minecraft.server.MinecraftServer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.PriorityBlockingQueue; +import java.util.concurrent.RejectedExecutionHandler; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; public final class ModdedScheduler implements PlatformScheduler { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); - private static final int ASYNC_CORE_THREADS = 2; private static final int ASYNC_MAX_THREADS = Math.max(4, Runtime.getRuntime().availableProcessors()); - private static final int ASYNC_QUEUE_CAPACITY = 4096; private static final long ASYNC_KEEP_ALIVE_SECONDS = 30L; + private static final int ASYNC_BACKLOG_WARN = 8192; + private static final long ASYNC_BACKLOG_WARN_INTERVAL_MILLIS = 30000L; + private static final long DRAIN_BUDGET_NANOS = TimeUnit.MILLISECONDS.toNanos(5L); + private static final int DRAIN_TASK_CAP = 512; private static volatile Thread mainThread; private volatile ThreadPoolExecutor asyncExecutor; private final ConcurrentLinkedQueue mainQueue = new ConcurrentLinkedQueue<>(); - private final ConcurrentLinkedQueue delayedQueue = new ConcurrentLinkedQueue<>(); + private final PriorityBlockingQueue delayedQueue = new PriorityBlockingQueue<>(); + private final AtomicLong currentTick = new AtomicLong(); + private final AtomicLong delayedSequence = new AtomicLong(); + private final AtomicLong lastBacklogWarnAt = new AtomicLong(); public ModdedScheduler() { this.asyncExecutor = createAsyncExecutor(); } private static ThreadPoolExecutor createAsyncExecutor() { - BlockingQueue workQueue = new ArrayBlockingQueue<>(ASYNC_QUEUE_CAPACITY); + // Unbounded queue: async work must never be executed inline on a tick thread (CallerRunsPolicy + // stalled the server tick under load). Core == max with core timeout keeps the pool elastic, + // which a LinkedBlockingQueue would otherwise pin to the core size. + BlockingQueue workQueue = new LinkedBlockingQueue<>(); ThreadPoolExecutor executor = new ThreadPoolExecutor( - ASYNC_CORE_THREADS, + ASYNC_MAX_THREADS, ASYNC_MAX_THREADS, ASYNC_KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, workQueue, new AsyncThreadFactory(), - new ThreadPoolExecutor.CallerRunsPolicy()); + dropRejectedTask()); executor.allowCoreThreadTimeOut(true); return executor; } + private static RejectedExecutionHandler dropRejectedTask() { + return (Runnable task, ThreadPoolExecutor executor) -> { + if (executor.isShutdown()) { + LOGGER.debug("Iris async task dropped: scheduler is shut down"); + return; + } + LOGGER.error("Iris async task rejected by the executor (queued={} active={})", + executor.getQueue().size(), executor.getActiveCount()); + }; + } + public static void tick(MinecraftServer server) { if (server == null) { return; } - mainThread = server.getRunningThread(); + Thread running = server.getRunningThread(); + if (mainThread != running) { + mainThread = running; + } + // First thing in the Iris tick body: keep the off-thread level snapshot current for levels registered + // outside ModdedServerAccess (vanilla boot, other mods) before the rest of the tick reads it. + ModdedServerLevels.refreshIfStale(server); ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); if (scheduler == null) { return; @@ -99,7 +125,9 @@ public final class ModdedScheduler implements PlatformScheduler { if (task == null) { return; } - asyncExecutor.execute(() -> runGuarded(task)); + ThreadPoolExecutor executor = asyncExecutor; + warnOnBacklog(executor); + executor.execute(() -> runGuarded(task)); } @Override @@ -111,7 +139,7 @@ public final class ModdedScheduler implements PlatformScheduler { global(task); return; } - delayedQueue.add(new DelayedTask(task, ticks)); + delayedQueue.add(new DelayedTask(currentTick.get() + ticks, delayedSequence.getAndIncrement(), task)); } @Override @@ -127,7 +155,10 @@ public final class ModdedScheduler implements PlatformScheduler { } mainQueue.clear(); delayedQueue.clear(); - mainThread = null; + // reset() only runs from ModdedEngineBootstrap.start at SERVER_STARTING, which every loader + // fires on the server thread: capture it here instead of waiting for the first tick, so + // global() cannot mistake a boot-time server-thread call for an off-thread one and defer it. + mainThread = Thread.currentThread(); } public void shutdown() { @@ -135,30 +166,54 @@ public final class ModdedScheduler implements PlatformScheduler { mainQueue.clear(); delayedQueue.clear(); mainThread = null; + // Shutdown stage that always runs (ModdedEngineBootstrap.stop): release the level snapshot with it. + ModdedServerLevels.forget(); + } + + private void warnOnBacklog(ThreadPoolExecutor executor) { + int queued = executor.getQueue().size(); + if (queued < ASYNC_BACKLOG_WARN) { + return; + } + long now = System.currentTimeMillis(); + long last = lastBacklogWarnAt.get(); + if (now - last < ASYNC_BACKLOG_WARN_INTERVAL_MILLIS || !lastBacklogWarnAt.compareAndSet(last, now)) { + return; + } + LOGGER.warn("Iris async backlog {} tasks (threads={}); async work is falling behind", queued, executor.getPoolSize()); } private void drain() { - promoteDelayed(); + long tick = currentTick.incrementAndGet(); + promoteDelayed(tick); + long deadline = System.nanoTime() + DRAIN_BUDGET_NANOS; + int executed = 0; Runnable task; while ((task = mainQueue.poll()) != null) { runGuarded(task); + executed++; + if (executed >= DRAIN_TASK_CAP || System.nanoTime() >= deadline) { + // Budget spent; the remainder stays queued in order and runs next tick. + break; + } } } - private void promoteDelayed() { - if (delayedQueue.isEmpty()) { - return; - } - List retained = new ArrayList<>(); - DelayedTask delayed; - while ((delayed = delayedQueue.poll()) != null) { - if (delayed.tick()) { - mainQueue.add(delayed.task()); - } else { - retained.add(delayed); + /** + * Due-ordered promotion; only the tick thread polls, so the head is always the earliest due task. A task + * added while this runs is due at least one tick after the tick being promoted, so it sorts behind + * everything this pass drains and is picked up on a later tick instead of being skipped. No per-tick + * rebuild of the pending set. + */ + private void promoteDelayed(long tick) { + DelayedTask head; + while ((head = delayedQueue.peek()) != null && head.dueTick() <= tick) { + DelayedTask delayed = delayedQueue.poll(); + if (delayed == null) { + return; } + mainQueue.add(delayed.task()); } - delayedQueue.addAll(retained); } private boolean onMainThread() { @@ -174,22 +229,11 @@ public final class ModdedScheduler implements PlatformScheduler { } } - private static final class DelayedTask { - private final Runnable task; - private int remaining; - - private DelayedTask(Runnable task, int remaining) { - this.task = task; - this.remaining = remaining; - } - - private boolean tick() { - remaining--; - return remaining <= 0; - } - - private Runnable task() { - return task; + private record DelayedTask(long dueTick, long sequence, Runnable task) implements Comparable { + @Override + public int compareTo(DelayedTask other) { + int byTick = Long.compare(dueTick, other.dueTick); + return byTick != 0 ? byTick : Long.compare(sequence, other.sequence); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerLevels.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerLevels.java index f2c1ab197..576f2ce54 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerLevels.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServerLevels.java @@ -23,18 +23,113 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.Level; import net.minecraft.world.level.storage.LevelStorageSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.ConcurrentModificationException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.concurrent.Executor; import java.util.function.Consumer; public final class ModdedServerLevels implements ModdedServerAccess { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final int CAPTURE_ATTEMPTS = 16; + private static volatile Snapshot snapshot; + private final Consumer levelCacheInvalidator; public ModdedServerLevels(Consumer levelCacheInvalidator) { this.levelCacheInvalidator = Objects.requireNonNull(levelCacheInvalidator); } + /** + * Immutable view of the loaded levels. {@code server.levels} is a plain map mutated on the server + * thread, so every off-server-thread reader must iterate this snapshot instead of + * {@code server.getAllLevels()} to avoid ConcurrentModificationException. + */ + public static List levels(MinecraftServer server) { + if (server == null) { + return List.of(); + } + Snapshot current = snapshot; + if (current != null && current.server() == server) { + return current.levels(); + } + Snapshot captured = capture(server); + return captured == null ? List.of() : captured.levels(); + } + + /** + * Keyed view of the loaded levels for off-server-thread lookups. Never used to gate injection: + * {@link #hasLevel} stays on the live map so a stale snapshot cannot mask a registered level. + */ + public static ServerLevel level(MinecraftServer server, ResourceKey key) { + if (server == null || key == null) { + return null; + } + Snapshot current = snapshot; + Snapshot resolved = current != null && current.server() == server ? current : capture(server); + return resolved == null ? null : resolved.byKey().get(key); + } + + /** + * Re-captures the snapshot when the live map no longer matches it. Server thread only; called once + * per tick so levels registered outside {@link ModdedServerAccess} (vanilla boot, other mods) are + * picked up without any reader touching the live map. + */ + public static void refreshIfStale(MinecraftServer server) { + if (server == null) { + return; + } + Snapshot current = snapshot; + if (current == null || current.server() != server) { + capture(server); + return; + } + List cached = current.levels(); + int index = 0; + for (ServerLevel level : server.getAllLevels()) { + if (index >= cached.size() || cached.get(index) != level) { + capture(server); + return; + } + index++; + } + if (index != cached.size()) { + capture(server); + } + } + + /** + * Drops the snapshot at shutdown so a stopped server (and its level graph) is not held alive until the + * next server publishes one. Integrated servers restart inside the same JVM. + */ + static void forget() { + snapshot = null; + } + + private static Snapshot capture(MinecraftServer server) { + for (int attempt = 0; attempt < CAPTURE_ATTEMPTS; attempt++) { + try { + LinkedHashMap, ServerLevel> byKey = new LinkedHashMap<>(); + for (ServerLevel level : server.getAllLevels()) { + byKey.put(level.dimension(), level); + } + Snapshot captured = new Snapshot(server, List.copyOf(byKey.values()), Map.copyOf(byKey)); + snapshot = captured; + return captured; + } catch (ConcurrentModificationException e) { + Thread.onSpinWait(); + } + } + LOGGER.error("Iris could not snapshot the level map after {} attempts; readers will see the previous snapshot", CAPTURE_ATTEMPTS); + Snapshot current = snapshot; + return current != null && current.server() == server ? current : null; + } + @Override public Executor levelExecutor(MinecraftServer server) { return server.executor; @@ -49,6 +144,7 @@ public final class ModdedServerLevels implements ModdedServerAccess { public ServerLevel putLevel(MinecraftServer server, ResourceKey key, ServerLevel level) { ServerLevel previous = server.levels.put(key, level); if (previous != level) { + capture(server); levelCacheInvalidator.accept(server); } return previous; @@ -58,6 +154,7 @@ public final class ModdedServerLevels implements ModdedServerAccess { public ServerLevel putLevelIfAbsent(MinecraftServer server, ResourceKey key, ServerLevel level) { ServerLevel previous = server.levels.putIfAbsent(key, level); if (previous == null) { + capture(server); levelCacheInvalidator.accept(server); } return previous; @@ -67,6 +164,7 @@ public final class ModdedServerLevels implements ModdedServerAccess { public ServerLevel removeLevel(MinecraftServer server, ResourceKey key) { ServerLevel removed = server.levels.remove(key); if (removed != null) { + capture(server); levelCacheInvalidator.accept(server); } return removed; @@ -76,4 +174,8 @@ public final class ModdedServerLevels implements ModdedServerAccess { public boolean hasLevel(MinecraftServer server, ResourceKey key) { return server.levels.containsKey(key); } + + private record Snapshot(MinecraftServer server, List levels, + Map, ServerLevel> byKey) { + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java index ce8e7bd2f..835a612f3 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedServiceManager.java @@ -86,17 +86,23 @@ public final class ModdedServiceManager { } } + /** + * Shutdown path: every service gets a disable attempt and nothing is rethrown. A throw here would abort the + * loader's remaining stop handlers, so failures are logged and the manager still ends up disabled. + */ public synchronized void disableAll() { if (!enabled) { return; } Throwable failure = null; + int failed = 0; ModdedService[] ordered = services.values().toArray(new ModdedService[0]); for (int i = ordered.length - 1; i >= 0; i--) { ModdedService service = ordered[i]; try { service.onDisable(); } catch (Throwable serviceFailure) { + failed++; LOGGER.error("Iris service onDisable failed for {}", service.getClass().getName(), serviceFailure); if (failure == null) { failure = serviceFailure; @@ -105,10 +111,10 @@ public final class ModdedServiceManager { } } } - if (failure != null) { - throw new IllegalStateException("One or more Iris services failed to disable", failure); - } enabled = false; + if (failure != null) { + LOGGER.error("Iris disabled all services with {} failure(s)", failed, failure); + } } synchronized void rollback(Throwable failure) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java index d4d4b91f5..991a549c8 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -24,14 +24,22 @@ import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidator; import art.arcane.iris.modded.command.ModdedPackCommands; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; public final class ModdedStartup { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); @@ -54,17 +62,26 @@ public final class ModdedStartup { validateAllPacks(); } + /** + * Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler: + * ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this + * one-shot task, and the datapack must be regenerated before the level PackRepository reload if it can. + */ public static void prefetchDefaultPack() { - ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); - if (scheduler != null) { - scheduler.async(ModdedStartup::ensureDefaultPack); - return; - } - Thread thread = new Thread(ModdedStartup::ensureDefaultPack, "iris-modded-pack-prefetch"); + Thread thread = new Thread(ModdedStartup::refreshPacksAndDatapack, "iris-modded-pack-prefetch"); thread.setDaemon(true); thread.start(); } + private static void refreshPacksAndDatapack() { + ensureDefaultPack(); + try { + ModdedForcedDatapack.regenerateIfStale("boot"); + } catch (Throwable failure) { + LOGGER.error("Iris could not refresh the forced datapack at boot", failure); + } + } + public static void runOnce(MinecraftServer server) { if (server == null || server.getPlayerList() == null) { return; @@ -74,14 +91,15 @@ public final class ModdedStartup { return; } ModdedForcedDatapack.verifyInjected(); + ModdedMixinAudit.runOnce(); reinjectPersistentDimensions(server); ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); if (scheduler == null) { - ensureDefaultPack(); + refreshPacksAndDatapack(); return; } - scheduler.async(ModdedStartup::ensureDefaultPack); + scheduler.async(ModdedStartup::refreshPacksAndDatapack); } public static void validateAllPacks() { @@ -133,6 +151,12 @@ public final class ModdedStartup { throw new BrokenPackException(pack, List.of( "Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + ".")); } + PackValidationResult cached = PackValidationRegistry.get(pack); + if (cached != null && cached.getValidatedAtMillis() >= newestModificationMillis(packDir.toPath())) { + // prepareForStartup already validated this pack and nothing in it changed since; re-validating per + // persistent dimension at boot costs a full pack parse each time. + return PackValidationRegistry.requireLoadable(pack); + } try { PackValidationResult result = PackValidator.validate(packDir); PackValidationRegistry.publish(result); @@ -156,23 +180,70 @@ public final class ModdedStartup { } private static void reinjectPersistentDimensions(MinecraftServer server) { - List dimensions = ModdedDimensionRegistryStore.load(server); + List dimensions = + ModdedDimensionRegistryStore.loadForStartup(server); if (dimensions.isEmpty()) { return; } int injected = 0; + int index = 0; + long startedAt = System.currentTimeMillis(); for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) { + index++; + long dimensionStartedAt = System.currentTimeMillis(); try { ModdedDimensionManager.create(server, dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed()); injected++; + LOGGER.info("Iris re-injected {}/{} '{}' (pack={} dim={}) in {}ms", + index, dimensions.size(), dimension.id(), dimension.pack(), dimension.dimension(), + System.currentTimeMillis() - dimensionStartedAt); } catch (Throwable e) { LOGGER.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), e); - if (e instanceof Error fatalError) { - throw fatalError; + if (e instanceof OutOfMemoryError outOfMemory) { + throw outOfMemory; } } } - LOGGER.info("Iris re-injected {} persistent dimension(s) at startup", injected); + LOGGER.info("Iris re-injected {}/{} persistent dimension(s) at startup in {}ms", + injected, dimensions.size(), System.currentTimeMillis() - startedAt); + } + + private static long newestModificationMillis(Path root) { + long newest = 0L; + try (Stream walk = Files.walk(root)) { + for (Path path : (Iterable) walk::iterator) { + BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class); + long modified = attributes.lastModifiedTime().toMillis(); + if (modified > newest) { + newest = modified; + } + } + } catch (IOException | RuntimeException unreadable) { + LOGGER.debug("Iris could not stat {} for validation reuse; revalidating", root, unreadable); + return Long.MAX_VALUE; + } + return newest; + } + + /** + * SP-6: a pack excluded by validation is otherwise only visible in the console. Tell the operators who + * can actually act on it when they join. + */ + public static void warnPackFailuresTo(ServerPlayer player) { + if (player == null || !Commands.LEVEL_GAMEMASTERS.check(player.permissions())) { + return; + } + for (Map.Entry entry : PackValidationRegistry.snapshot().entrySet()) { + PackValidationResult result = entry.getValue(); + if (result == null || result.isLoadable()) { + continue; + } + String reason = result.getBlockingErrors().isEmpty() + ? "unknown validation failure" + : result.getBlockingErrors().getFirst(); + player.sendSystemMessage(Component.literal("Iris pack '" + entry.getKey() + + "' failed validation and cannot be used: " + reason)); + } } public static void ensureDefaultPack() { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java index 223a5649c..dc33b73cc 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java @@ -59,6 +59,13 @@ import java.util.Set; import java.util.function.Supplier; public final class ModdedStructureHooks implements PlatformStructureHooks { + /** + * Hard ceiling on the chunk grid a single capture placement may touch. placeChunks loads every chunk in + * the grid synchronously on the server thread, so a structure with a runaway bounding box (or maxSpan 0, + * which disables the span check entirely) would otherwise stall the server for thousands of chunk loads. + */ + static final int MAX_PLACEMENT_CHUNKS = 1024; + private final Supplier server; public ModdedStructureHooks(Supplier server) { @@ -182,6 +189,13 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { return keys; } + /** + * Swallow contract: a feature that refuses to place, or that throws while placing, is a normal outcome for + * the importer that drives this - it probes many keys against arbitrary terrain and treats false as "not + * here". So every Throwable is reported through IrisLogging and answered with false; placement never + * escalates to the caller. placeStructure below deliberately does the opposite (it rethrows with context) + * because a failed structure capture means the capture pass is broken, not that the site was unsuitable. + */ @Override public boolean placeFeature(PlatformWorld world, int x, int y, int z, String featureKey, long seed) { try { @@ -212,6 +226,10 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { if (level == null || identifier == null) { return null; } + if (!level.getServer().isSameThread()) { + throw new IllegalStateException("Structure placement loads chunks synchronously and must run on the server thread, not " + + Thread.currentThread().getName()); + } ChunkGenerator generator = level.getChunkSource().getGenerator(); Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); Structure structure = registry.getValue(identifier); @@ -244,6 +262,12 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { if (!isWithinSpan(box, maxSpan)) { return null; } + int placementChunks = chunkGridSize(box); + if (placementChunks > MAX_PLACEMENT_CHUNKS) { + IrisLogging.warn("Skipped structure capture for " + structureKey + " at " + chunkX + "," + chunkZ + + ": bounding box spans " + placementChunks + " chunks, cap is " + MAX_PLACEMENT_CHUNKS); + return null; + } placeChunks(level, structureManager, generator, start, box, seed); return bounds(box); } catch (RuntimeException error) { @@ -275,6 +299,20 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { || box.getXSpan() <= maxSpan && box.getYSpan() <= maxSpan && box.getZSpan() <= maxSpan; } + /** + * Number of chunks placeChunks would load for this bounding box. Computed in long arithmetic because a + * corrupt box can span the whole coordinate range and the product overflows int. + */ + static int chunkGridSize(BoundingBox box) { + long spanX = ((long) (box.maxX() >> 4)) - (box.minX() >> 4) + 1L; + long spanZ = ((long) (box.maxZ() >> 4)) - (box.minZ() >> 4) + 1L; + if (spanX <= 0L || spanZ <= 0L) { + return 0; + } + long total = spanX * spanZ; + return total > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) total; + } + static int[] bounds(BoundingBox box) { return new int[]{box.minX(), box.minY(), box.minZ(), box.maxX(), box.maxY(), box.maxZ()}; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileData.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileData.java index dea603a86..59612886f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileData.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileData.java @@ -19,12 +19,15 @@ package art.arcane.iris.modded; import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.data.UnresolvedKeyLog; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.Strictness; import net.minecraft.nbt.ByteTag; +import net.minecraft.nbt.CollectionTag; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.DoubleTag; import net.minecraft.nbt.FloatTag; @@ -32,6 +35,7 @@ import net.minecraft.nbt.IntTag; import net.minecraft.nbt.ListTag; import net.minecraft.nbt.LongTag; import net.minecraft.nbt.NbtUtils; +import net.minecraft.nbt.NumericTag; import net.minecraft.nbt.ShortTag; import net.minecraft.nbt.StringTag; import net.minecraft.nbt.Tag; @@ -52,18 +56,24 @@ import net.minecraft.world.level.storage.TagValueInput; import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Objects; public final class ModdedTileData extends TileData { public static final String NBT_PROPERTY = "nbt"; static final String LEGACY_BANNER_COLOR_PROPERTY = "iris:legacy_banner_color"; + private static final int MAX_TAG_DEPTH = 64; private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setStrictness(Strictness.LENIENT).create(); + private static final UnresolvedKeyLog SNBT_FALLBACK = new UnresolvedKeyLog("Iris tile capture SNBT fallback", 30_000L); private final byte[] raw; private final KMap tileProperties; private final String expectedBlockKey; private final int legacyType; + private int hash; ModdedTileData(byte[] raw, KMap tileProperties, String expectedBlockKey, int legacyType) { super(); @@ -74,8 +84,7 @@ public final class ModdedTileData extends TileData { } public static ModdedTileData capture(String blockKey, String snbt) throws IOException { - KMap properties = new KMap<>(); - properties.put(NBT_PROPERTY, snbt); + KMap properties = captureProperties(blockKey, snbt); ByteArrayOutputStream bytes = new ByteArrayOutputStream(); try (DataOutputStream out = new DataOutputStream(bytes)) { out.writeUTF(blockKey); @@ -84,6 +93,44 @@ public final class ModdedTileData extends TileData { return new ModdedTileData(bytes.toByteArray(), properties, normalizeBlockKey(blockKey), -1); } + /** + * Converts a captured tile to the generic map form the Bukkit side reads and writes, so an object captured on a + * mod loader still pastes on Bukkit, and keeps the original SNBT under {@value #NBT_PROPERTY} alongside it. + *

+ * Both forms are stored because the map form is lossy: {@link #fromTag(Tag, int, int)} collapses ByteArray, + * IntArray and LongArray tags to a plain List, and pasting that back produces a ListTag, which Minecraft rejects + * where it expects an array - a player head's {@code profile.id} (IntArray of 4) is the common case. Modded paste + * reads the SNBT first ({@link #payload()}), so a modded capture pastes byte-identical on a mod loader; Bukkit + * still reads the map form and accepts the array-shaped members degrading to lists, as it did before. + *

+ * The SNBT is dropped when the captured tag itself has a root member named {@code nbt}, since that member owns the + * map key; such a tile keeps the pre-existing lossy behaviour on both platforms. No vanilla block entity has one. + */ + @SuppressWarnings("unchecked") + private static KMap captureProperties(String blockKey, String snbt) { + KMap properties = new KMap<>(); + if (snbt != null && !snbt.isBlank()) { + try { + Object converted = fromTag(NbtUtils.snbtToStructure(snbt), 0, MAX_TAG_DEPTH); + if (converted instanceof KMap map) { + properties.putAll((KMap) map); + } + } catch (Throwable e) { + if (SNBT_FALLBACK.firstOccurrence(blockKey == null ? "" : blockKey)) { + IrisLogging.warn("Tile capture for '" + blockKey + "' kept SNBT form: " + e.getMessage()); + } + String summary = SNBT_FALLBACK.pollSummary(); + if (summary != null) { + IrisLogging.warn(summary); + } + } + } + if (snbt != null && !properties.containsKey(NBT_PROPERTY)) { + properties.put(NBT_PROPERTY, snbt); + } + return properties; + } + public static ModdedTileData fromProperties(PlatformBlockState state, KMap properties) { String blockKey = state.placementBaseState().key(); int bracket = blockKey.indexOf('['); @@ -121,6 +168,17 @@ public final class ModdedTileData extends TileData { return true; } + /** + * The platform-neutral block key. The superclass reads its own {@code material} field, which a modded record + * never populates (it carries the key as {@link #expectedBlockKey} instead), so it must be answered here. + * Null for a legacy record, which identifies its target by block-entity type rather than by key - see + * {@link #isApplicable(BlockState, BlockEntity)}. + */ + @Override + public String getMaterialKey() { + return expectedBlockKey; + } + public boolean isApplicable(BlockState state, BlockEntity blockEntity) { if (expectedBlockKey != null) { return expectedBlockKey.equals(normalizeBlockKey(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString())); @@ -218,10 +276,70 @@ public final class ModdedTileData extends TileData { return StringTag.valueOf(String.valueOf(value)); } + /** + * Inverse of {@link #toTag(Object)}, matching the Bukkit NMS tile converter value-for-value so both platforms + * produce the same generic map for the same block entity. + */ + private static Object fromTag(Tag tag, int depth, int maxDepth) { + if (tag == null || depth > maxDepth) { + return null; + } + if (tag instanceof CompoundTag compound) { + KMap map = new KMap<>(); + for (String key : compound.keySet()) { + Tag child = compound.get(key); + if (child == null) { + continue; + } + Object value = fromTag(child, depth + 1, maxDepth); + if (value != null) { + map.put(key, value); + } + } + return map; + } + if (tag instanceof CollectionTag collection) { + List values = new ArrayList<>(); + for (Object entry : collection) { + if (entry instanceof Tag child) { + Object value = fromTag(child, depth + 1, maxDepth); + if (value != null) { + values.add(value); + } + } else if (entry != null) { + values.add(entry); + } + } + return values; + } + if (tag instanceof NumericTag numeric) { + return numeric.box(); + } + return tag.asString().orElse(null); + } + private static > BlockState copyProperty(BlockState target, BlockState source, Property property) { return target.setValue(property, source.getValue(property)); } + private static Object deepCopy(Object value) { + if (value instanceof Map map) { + KMap copy = new KMap<>(); + for (Map.Entry entry : map.entrySet()) { + copy.put(String.valueOf(entry.getKey()), deepCopy(entry.getValue())); + } + return copy; + } + if (value instanceof List values) { + List copy = new ArrayList<>(values.size()); + for (Object entry : values) { + copy.add(deepCopy(entry)); + } + return copy; + } + return value; + } + static String normalizeBlockKey(String blockKey) { if (blockKey == null || blockKey.isBlank()) { return null; @@ -248,8 +366,53 @@ public final class ModdedTileData extends TileData { out.write(raw); } + /** + * Identity over this record's own state. The superclass generates equals/hashCode from its + * {@code material} and {@code properties} fields, both of which stay null on a modded record, which would make + * every modded tile equal with a constant hash. Mantle tile sections are palette backed + * (16x16x16 = 4096 entries, so PaletteOrHunk picks a value-keyed DataContainer) and resolve palette ids through + * equals, so a collapsed identity writes the first tile's NBT into every other tile in the section. + *

+ * The serialized {@link #raw} form is the complete identity: it carries the block key and the property JSON for a + * modern record and the consumed bytes for a legacy one. Two logically identical tiles still share one palette + * entry, which is the intended dedup. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ModdedTileData other)) { + return false; + } + return legacyType == other.legacyType + && Objects.equals(expectedBlockKey, other.expectedBlockKey) + && Arrays.equals(raw, other.raw); + } + + @Override + public int hashCode() { + int cached = hash; + if (cached != 0) { + return cached; + } + int computed = 31 * (31 * Arrays.hashCode(raw) + Objects.hashCode(expectedBlockKey)) + legacyType; + if (computed == 0) { + computed = 1; + } + hash = computed; + return computed; + } + + @Override + public String toString() { + return (expectedBlockKey == null ? "legacy:" + legacyType : expectedBlockKey) + GSON.toJson(tileProperties); + } + + @SuppressWarnings("unchecked") @Override public TileData clone() { - return this; + return new ModdedTileData(raw == null ? null : raw.clone(), + (KMap) deepCopy(tileProperties), expectedBlockKey, legacyType); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java index 20a18a5bf..896402805 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java @@ -23,6 +23,9 @@ import art.arcane.iris.modded.WorldCheckStructureAudit.PendingVillagePoi; import art.arcane.iris.modded.WorldCheckStructureAudit.PoiAudit; import net.minecraft.core.BlockPos; import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.block.state.BlockState; @@ -39,6 +42,8 @@ import java.util.HexFormat; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BooleanSupplier; @@ -47,8 +52,11 @@ public final class ModdedWorldCheck { private static final int EXIT_FAILURE = 1; private static final long SERVER_WAIT_TIMEOUT_MILLIS = 600000L; private static final long SERVER_WAIT_INTERVAL_MILLIS = 250L; + private static final long SERVER_TASK_TIMEOUT_MILLIS = 900000L; private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); - private static final ProcessExit PROCESS_EXIT = Runtime.getRuntime()::exit; + // halt, not exit: awaitStopAndExit already waited for MinecraftServer.halt(true), and exit() would run the + // shutdown hooks and block behind the server thread it just stopped, so a finished check could hang forever. + private static final ProcessExit PROCESS_EXIT = Runtime.getRuntime()::halt; private static volatile MinecraftServer startedServer; private ModdedWorldCheck() { @@ -70,7 +78,9 @@ public final class ModdedWorldCheck { static Thread coordinatorThread(Runnable coordinator) { Thread thread = new Thread(coordinator, "Iris World Check"); - thread.setDaemon(false); + // Daemon: every wait below is bounded and the coordinator exits the process itself, so this thread + // must never be the reason a crashed dev server keeps the JVM alive. + thread.setDaemon(true); return thread; } @@ -95,17 +105,20 @@ public final class ModdedWorldCheck { } MinecraftServer serverRef = server; - WorldCheckPreparation preparation = serverRef.submit(() -> run(serverRef)).join(); + WorldCheckPreparation preparation = serverRef.submit(() -> run(serverRef)) + .get(SERVER_TASK_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); exitCode = serverRef.submit(() -> runAndRequestStop( () -> completeWorldCheck(preparation), () -> { stopRequested.set(true); serverRef.halt(false); } - )).join(); + )).get(SERVER_TASK_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { LOGGER.error("[worldcheck] coordinator interrupted", e); Thread.currentThread().interrupt(); + } catch (TimeoutException e) { + LOGGER.error("[worldcheck] server task did not finish within {}ms", SERVER_TASK_TIMEOUT_MILLIS); } catch (Throwable e) { LOGGER.error("[worldcheck] check failed", e); } finally { @@ -275,16 +288,18 @@ public final class ModdedWorldCheck { private static ServerLevel targetLevel(MinecraftServer server) { String target = System.getProperty("iris.worldcheck.dimension"); if (target != null && !target.isBlank()) { - for (ServerLevel level : server.getAllLevels()) { - if (level.dimension().identifier().toString().equals(target.trim())) { - return level; - } + Identifier identifier = Identifier.tryParse(target.trim()); + ServerLevel requested = identifier == null + ? null + : ModdedServerLevels.level(server, ResourceKey.create(Registries.DIMENSION, identifier)); + if (requested != null) { + return requested; } LOGGER.error("[worldcheck] requested dimension '{}' is not loaded", target); return null; } - for (ServerLevel level : server.getAllLevels()) { + for (ServerLevel level : ModdedServerLevels.levels(server)) { if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) { return level; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java index 8f894e41f..b6fdc24ff 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java @@ -45,14 +45,17 @@ import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterMarker; import art.arcane.volmlib.util.matter.slices.MarkerMatter; -import it.unimi.dsi.fastutil.longs.LongArrayList; +import it.unimi.dsi.fastutil.objects.Object2IntMap; import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.MobCategory; +import net.minecraft.world.level.NaturalSpawner; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LevelChunk; import net.minecraft.world.phys.AABB; import java.util.HashSet; @@ -70,20 +73,23 @@ public final class ModdedWorldManager implements EngineWorldManager { private static final int MAX_INITIAL_DRAIN_PER_TICK = 8; private static final int MAX_INITIAL_RECOVERY_PER_PASS = 128; private static final int MANTLE_WARMUP_QUEUE_CAPACITY = 256; + private static final int AMBIENT_CHUNK_SAMPLE = 64; private static final long INITIAL_RECOVERY_INTERVAL_MS = 1_000L; - private static final long COUNT_INTERVAL_MS = 3_000L; private final Engine engine; private final InitialSpawnQueue initialSpawnQueue; private final Set mantleWarmups; private final ThreadPoolExecutor mantleWarmupExecutor; + private final long[] ambientChunkSample = new long[AMBIENT_CHUNK_SAMPLE]; + private int ambientChunkSampleSeen; private long lastAmbientAt; - private long lastCountAt; private long lastInitialRecoveryAt; private boolean initialSpawnQueueClosed; private boolean mantleWarmupExecutorStopped; private boolean mantleWarmupsCleared; + private boolean spawnStateMissingLogged; private volatile boolean closed; + private volatile boolean entityCountAvailable; private volatile int cachedEntityCount; private volatile int cachedConsideredChunks; private volatile double cachedSaturation; @@ -114,9 +120,16 @@ public final class ModdedWorldManager implements EngineWorldManager { return; } EngineWorldManager worldManager = engine.getWorldManager(); - if (worldManager instanceof ModdedWorldManager moddedWorldManager) { - moddedWorldManager.initialSpawnQueue.offer(pack(chunkX, chunkZ)); + if (!(worldManager instanceof ModdedWorldManager moddedWorldManager)) { + return; } + if (moddedWorldManager.closed || moddedWorldManager.isPregenActive()) { + // runServerTick skips the drain while a pregen targets this world, so every offer from the + // generation threads can only expire or overflow while contending for the queue monitor. + // recoverLoadedInitialSpawns re-offers the chunks that matter once the job ends. + return; + } + moddedWorldManager.initialSpawnQueue.offer(pack(chunkX, chunkZ)); } public void serverTick(ServerLevel level) { @@ -328,19 +341,23 @@ public final class ModdedWorldManager implements EngineWorldManager { return; } - long[] candidates = loadedChunkPositionsSnapshot(level); - refreshEntityCount(level, now, candidates.length); + int loadedChunks = sampleLoadedChunks(level); + refreshEntityCount(level, loadedChunks); + if (!entityCountAvailable) { + return; + } if (cachedSaturation > IrisSettings.get().getWorld().getTargetSpawnEntitiesPerChunk()) { return; } - if (candidates.length == 0) { + int sampled = Math.min(loadedChunks, ambientChunkSample.length); + if (sampled == 0) { return; } int spawnBuffer = RNG.r.i(2, 12); while (spawnBuffer-- > 0) { - long key = candidates[RNG.r.nextInt(candidates.length)]; + long key = ambientChunkSample[RNG.r.nextInt(sampled)]; try { ambientSpawnChunk(level, unpackX(key), unpackZ(key)); } catch (Throwable e) { @@ -673,29 +690,66 @@ public final class ModdedWorldManager implements EngineWorldManager { return (position.getX() >> 4) == chunkX && (position.getZ() >> 4) == chunkZ; } - private void refreshEntityCount(ServerLevel level, long now, int loadedChunks) { + /** + * ServerChunkCache.tickChunks rebuilds NaturalSpawner.SpawnState every tick from every entity in the + * level, so read that instead of walking all entities again on an Iris timer. The count is the natural + * spawn cap population (non persistent mobs in loaded chunks, MISC excluded), which is exactly the + * population the ambient spawn gate throttles against. No state means the level has not ticked chunks + * yet, so hold spawning rather than guess. + */ + private void refreshEntityCount(ServerLevel level, int loadedChunks) { cachedConsideredChunks = loadedChunks; - cachedSaturation = cachedEntityCount / (loadedChunks + 1.0) * 1.28; - if (now - lastCountAt < COUNT_INTERVAL_MS) { + NaturalSpawner.SpawnState spawnState = level.getChunkSource().getLastSpawnState(); + if (spawnState == null) { + entityCountAvailable = false; + if (!spawnStateMissingLogged) { + spawnStateMissingLogged = true; + IrisLogging.warn("No spawn state for " + engine.getName() + " yet; ambient spawning held"); + } return; } - lastCountAt = now; - int livingEntities = 0; - for (Entity entity : level.getAllEntities()) { - if (entity instanceof LivingEntity && entity.isAlive()) { - livingEntities++; - } + Object2IntMap counts = spawnState.getMobCategoryCounts(); + int mobs = 0; + for (MobCategory category : counts.keySet()) { + mobs += counts.getInt(category); } - cachedEntityCount = livingEntities; - cachedSaturation = livingEntities / (loadedChunks + 1.0) * 1.28; + entityCountAvailable = true; + cachedEntityCount = mobs; + // Metric = natural-spawn-cap population over natural-spawn chunk count: numerator and denominator both + // come from MC's own spawn state, so the ratio is not diluted by chunks the spawner never counts. + cachedSaturation = mobs / (spawnState.getSpawnableChunkCount() + 1.0) * 1.28; } - private long[] loadedChunkPositionsSnapshot(ServerLevel level) { - LongArrayList positions = new LongArrayList(level.getChunkSource().getLoadedChunksCount()); - level.getChunkSource().chunkMap.forEachReadyToSendChunk(chunk -> positions.add(chunk.getPos().pack())); - return positions.toLongArray(); + /** + * Reservoir sample (algorithm R) of the ready to send chunks into a fixed buffer. ambientTick only picks + * up to 12 random chunks per pass, so materializing every loaded chunk position once per interval was + * pure garbage. Returns how many chunks the walk saw, which is the same considered-chunk count the old + * snapshot length reported. Server thread only, so the reservoir and its counter are plain fields. + */ + private int sampleLoadedChunks(ServerLevel level) { + ambientChunkSampleSeen = 0; + level.getChunkSource().chunkMap.forEachReadyToSendChunk((LevelChunk chunk) -> { + int index = ambientChunkSampleSeen++; + int capacity = ambientChunkSample.length; + int slot = reservoirSlot(index, capacity, index < capacity ? 0 : RNG.r.nextInt(index + 1)); + if (slot >= 0) { + ambientChunkSample[slot] = chunk.getPos().pack(); + } + }); + return ambientChunkSampleSeen; + } + + /** + * Algorithm R slot for the item at {@code index}: fill the reservoir first, then keep the item only when + * {@code roll} (uniform over 0..index) lands inside the reservoir. Negative means drop the item. + */ + static int reservoirSlot(int index, int capacity, int roll) { + if (index < capacity) { + return index; + } + return roll < capacity ? roll : -1; } private boolean isPregenActive() { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckFeaturePlacement.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckFeaturePlacement.java new file mode 100644 index 000000000..b19395227 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/WorldCheckFeaturePlacement.java @@ -0,0 +1,159 @@ +/* + * 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 . + */ + +package art.arcane.iris.modded; + +import net.minecraft.world.level.ChunkPos; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.Filter; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.Logger; +import org.apache.logging.log4j.core.filter.AbstractFilter; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * {@code feature_placement} worldcheck gate for the imported-feature pass. + * + *

Vanilla does not throw when a feature writes into a chunk it is not allowed to touch; it logs + * {@code Detected setBlock in a far chunk} and drops the write. Reading back the world cannot distinguish that + * from a feature that legitimately placed nothing, so the gate watches the log instead. The second marker, + * {@code Requested chunk unavailable during world generation}, does throw, and is recorded from the feature + * pass directly. + * + *

Only armed under {@code -Diris.worldcheck}. Outside a gated run every entry point here is a single + * boolean read. + */ +final class WorldCheckFeaturePlacement { + private static final boolean ENABLED = Boolean.getBoolean("iris.worldcheck"); + private static final String FAR_CHUNK_MARKER = "Detected setBlock in a far chunk"; + private static final String UNAVAILABLE_CHUNK_MARKER = "Requested chunk unavailable during world generation"; + private static final List MARKERS = List.of(FAR_CHUNK_MARKER, UNAVAILABLE_CHUNK_MARKER); + private static final int REPORTED_SAMPLE_MAX = 5; + + private static final AtomicBoolean INSTALLED = new AtomicBoolean(); + private static final AtomicBoolean PASS_REPORTED = new AtomicBoolean(); + private static final AtomicBoolean SKIP_REPORTED = new AtomicBoolean(); + private static final AtomicInteger VIOLATIONS = new AtomicInteger(); + + private WorldCheckFeaturePlacement() { + } + + /** + * Arms the log watch. Called when the feature table is published, which is before any chunk decorates, so + * the watch is installed for the very first pass rather than from the second chunk onwards. The pass and + * failure recorders arm too, for a path that publishes a table without going through prepare. Idempotent + * and cheap when the gate is off. + */ + static void arm() { + if (!ENABLED || !INSTALLED.compareAndSet(false, true)) { + return; + } + try { + ((Logger) LogManager.getRootLogger()).addFilter(new PlacementWatch()); + } catch (Throwable error) { + WorldCheckPredicates.qaEvent("feature_placement", "all", false, + "skipped=log-watch-unavailable," + error.getClass().getSimpleName()); + } + } + + /** + * Emits the passing gate event once, after the first clean chunk. A later violation emits its own failing + * event, so a run that fails after passing still reports the failure. + */ + static void recordPlacementPass() { + if (!ENABLED) { + return; + } + arm(); + if (VIOLATIONS.get() == 0 && PASS_REPORTED.compareAndSet(false, true)) { + WorldCheckPredicates.qaEvent("feature_placement", "all", true, "violations=0"); + } + } + + /** + * Emits the not-asserted event once, so a gated run can tell "importedFeatures was off, nothing to check" + * apart from "checked and clean". + */ + static void recordFeaturesOff() { + if (!ENABLED) { + return; + } + if (SKIP_REPORTED.compareAndSet(false, true)) { + WorldCheckPredicates.qaEvent("feature_placement", "all", true, "skipped=importedFeatures-off"); + } + } + + static void recordPlacementFailure(ChunkPos chunkPos, Throwable error) { + if (!ENABLED) { + return; + } + arm(); + String message = messageChain(error); + String detail = message.contains(UNAVAILABLE_CHUNK_MARKER) + ? "unavailableChunk" + : "placementFailure"; + report(detail + ",chunk=" + chunkPos.x() + "," + chunkPos.z() + ",error=" + + error.getClass().getSimpleName()); + } + + private static void report(String detail) { + int count = VIOLATIONS.incrementAndGet(); + if (count <= REPORTED_SAMPLE_MAX) { + WorldCheckPredicates.qaEvent("feature_placement", "all", false, detail + ",violations=" + count); + } + } + + private static String messageChain(Throwable error) { + StringBuilder chain = new StringBuilder(); + Throwable current = error; + for (int depth = 0; current != null && depth < 16; depth++) { + if (current.getMessage() != null) { + chain.append(current.getMessage()).append('\n'); + } + current = current.getCause() == current ? null : current.getCause(); + } + return chain.toString(); + } + + private static final class PlacementWatch extends AbstractFilter { + private PlacementWatch() { + super(Filter.Result.NEUTRAL, Filter.Result.NEUTRAL); + } + + @Override + public Filter.Result filter(LogEvent event) { + if (event == null || event.getMessage() == null) { + return Filter.Result.NEUTRAL; + } + String message = event.getMessage().getFormattedMessage(); + if (message == null) { + return Filter.Result.NEUTRAL; + } + for (String marker : MARKERS) { + if (message.contains(marker)) { + report("logMarker=" + marker); + break; + } + } + return Filter.Result.NEUTRAL; + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java index af3f37bd5..61e8a2890 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java @@ -29,11 +29,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.ServiceLoader; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; @@ -216,6 +219,44 @@ public final class ModdedCustomContentRegistry { return !PROVIDERS.isEmpty() || !CUSTOM_BLOCKS.isEmpty(); } + /** + * Every statically registered {@code namespace:key} block alias. Read-only snapshot for pack tooling + * (schema completion, key validation); not on the resolution path. + */ + public static List aliasBlockKeys() { + return List.copyOf(CUSTOM_BLOCKS.keySet()); + } + + /** + * Every key claimed by a registered provider for {@code type}, in registration order, deduplicated. Read-only + * snapshot for pack tooling; a provider that throws is logged against its mod id and skipped. + */ + public static List providerKeys(ModdedDataType type) { + if (type == null || PROVIDERS.isEmpty()) { + return List.of(); + } + List keys = new ArrayList<>(); + Set seen = new HashSet<>(); + for (ModdedDataProvider provider : PROVIDERS) { + Collection types; + try { + types = provider.getTypes(type); + } catch (Throwable error) { + LOGGER.error("Iris custom content provider '{}' failed listing {} types", provider.modId(), type, error); + continue; + } + if (types == null) { + continue; + } + for (Identifier identifier : types) { + if (identifier != null && seen.add(identifier.toString())) { + keys.add(identifier.toString()); + } + } + } + return List.copyOf(keys); + } + /** * Resolves a pack block key against aliases first, then each ready provider that claims it, in registration * order. {@code key} may carry {@code [prop=value]} properties, which are parsed and passed along. Returns null diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index 9b34b9992..35a5a6a69 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -25,9 +25,11 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedEngineBootstrap; +import art.arcane.iris.modded.ModdedForcedDatapack; import art.arcane.iris.modded.ModdedLoader; import art.arcane.iris.modded.ModdedPackInstaller; import art.arcane.iris.modded.ModdedScheduler; +import art.arcane.iris.modded.ModdedServerLevels; import art.arcane.iris.modded.ModdedWorldgenIds; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KMap; @@ -121,6 +123,9 @@ public final class IrisModdedCommands { IrisSettings.invalidate(); } IrisSettings.get(); + // Forced-datapack regeneration trigger. Async: staging revalidates every pack and must never run on + // the server thread. + ModdedForcedDatapack.scheduleRegeneration("/iris reload"); boolean localeLoaded = IrisLanguage.reload(); if (localeLoaded) { ok(source, IrisLanguage.plain( @@ -178,10 +183,13 @@ public final class IrisModdedCommands { static int info(CommandSourceStack source, String filter) { MinecraftServer server = source.getServer(); + // The seed is the one field in this listing that is not free to hand a plain player, and /iris worlds + // routes here too: emit it only for sources that pass the same gate /iris seed requires. + boolean showSeed = ModdedCommandTree.isGamemaster(source); List lines = new ArrayList<>(); int total = 0; int iris = 0; - for (ServerLevel level : server.getAllLevels()) { + for (ServerLevel level : ModdedServerLevels.levels(server)) { total++; ChunkGenerator generator = level.getChunkSource().getGenerator(); if (!(generator instanceof IrisModdedChunkGenerator irisGenerator)) { @@ -201,12 +209,14 @@ public final class IrisModdedCommands { + " world=" + dimensionId + " (engine not started yet)"); continue; } + String featureStatus = irisGenerator.importedFeaturesStatus(); lines.add(irisIdentity + ": pack=" + engine.getDimension().getLoadKey() + " world=" + dimensionId - + " seed=" + level.getSeed() + + (showSeed ? " seed=" + level.getSeed() : "") + " height=" + engine.getMinHeight() + ".." + engine.getMaxHeight() + " generated=" + engine.getGenerated() - + " data=" + engine.getData().getDataFolder().getAbsolutePath()); + + (featureStatus == null ? "" : " importedFeatures=" + featureStatus) + + " data=" + engine.getData().getDataFolder().getName()); } ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_LOADED_DIMENSIONS_IRIS, MessageArgument.untrusted("total", total), MessageArgument.untrusted("iris", iris))); if (lines.isEmpty()) { @@ -321,7 +331,7 @@ public final class IrisModdedCommands { private static int engineCount(MinecraftServer server) { int count = 0; - for (ServerLevel level : server.getAllLevels()) { + for (ServerLevel level : ModdedServerLevels.levels(server)) { if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) { count++; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java index 2d08701c3..6814fe948 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java @@ -231,13 +231,17 @@ final class ModdedCommandHelp { return 0; } + ModdedCommandFeedback.clear(source); + + if (source.getPlayer() == null) { + return sendConsole(source, request.section()); + } + int totalPages = Math.max(1, (int) Math.ceil(entries.size() / (double) PAGE_SIZE)); int page = Math.max(0, Math.min(request.page(), totalPages - 1)); int from = page * PAGE_SIZE; int to = Math.min(entries.size(), from + PAGE_SIZE); - ModdedCommandFeedback.clear(source); - sendHeader(source, request.section(), page, totalPages); if (!Commands.hasPermission(Commands.LEVEL_GAMEMASTERS).test(source)) { ModdedCommandFeedback.send(source, opNotice()); @@ -252,6 +256,45 @@ final class ModdedCommandHelp { return 1; } + private static int sendConsole(CommandSourceStack source, String section) { + sendHeader(source, section, 0, 1); + if (!Commands.hasPermission(Commands.LEVEL_GAMEMASTERS).test(source)) { + ModdedCommandFeedback.send(source, opNotice()); + } + for (String line : consoleLines(section)) { + ModdedCommandFeedback.send(source, Component.literal(line)); + } + return 1; + } + + static List consoleLines(String section) { + List entries = SECTIONS.get(section); + if (entries == null) { + return List.of(); + } + + List lines = new ArrayList<>(entries.size()); + for (Entry entry : entries) { + lines.add(consoleLine(section, entry)); + } + return lines; + } + + private static String consoleLine(String section, Entry entry) { + StringBuilder line = new StringBuilder(section.isEmpty() ? "/iris " : "/iris " + section + " "); + line.append(entry.name()); + if (!entry.usage().isBlank()) { + line.append(' ').append(entry.usage()); + } + if (entry.aliases().length > 0) { + line.append(" (").append(String.join(", ", entry.aliases())).append(')'); + } + if (entry.group()) { + line.append(" - ").append(IrisLanguage.plain(DirectorHelpMessages.CATEGORY)); + } + return line.append(" - ").append(IrisLanguage.plain(entry.description())).toString(); + } + private static void sendHeader(CommandSourceStack source, String path, int page, int totalPages) { String title = path.isEmpty() ? "/iris" : "/iris " + path; if (totalPages > 1) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java index 6da9f6fa4..07f01fb63 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java @@ -22,6 +22,7 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedEngineBootstrap; +import art.arcane.iris.modded.ModdedServerLevels; import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.suggestion.SuggestionProvider; import com.mojang.brigadier.suggestion.Suggestions; @@ -181,7 +182,7 @@ final class ModdedCommandSuggestions { private static CompletableFuture suggestDimensionNames(CommandContext context, SuggestionsBuilder builder) { ModdedCommandFeedback.tab(context.getSource()); List names = new ArrayList<>(); - for (ServerLevel level : context.getSource().getServer().getAllLevels()) { + for (ServerLevel level : ModdedServerLevels.levels(context.getSource().getServer())) { if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) { names.add(level.dimension().identifier().toString()); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java index 27ac48273..4f973d69c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java @@ -36,25 +36,42 @@ import java.util.function.Predicate; final class ModdedCommandTree { private static final Predicate GATE = Commands.hasPermission(Commands.LEVEL_GAMEMASTERS); + /** + * SP-4: read-only inspection must work for an unopped player in a no-cheats singleplayer world, where the + * what/height overlays are the only way to see what Iris generated. Everything that mutates a world, + * downloads, opens studio or starts a pregen stays on GATE, and so does the world seed: it is the one + * read-only value a plain player must not be handed, so /iris seed is gated and info/worlds omit the seed + * field for anyone who fails {@link #isGamemaster(CommandSourceStack)}. + */ + private static final Predicate READ_ONLY = Commands.hasPermission(Commands.LEVEL_ALL); private ModdedCommandTree() { } + /** + * Same gate the mutating subtrees use, for output that mixes gated and ungated fields in one command. + */ + static boolean isGamemaster(CommandSourceStack source) { + return GATE.test(source); + } + static LiteralArgumentBuilder rootTree() { LiteralArgumentBuilder root = Commands.literal("iris"); root.executes((CommandContext context) -> ModdedCommandHelp.send(context.getSource(), "")); root.then(helpTree()); - root.then(Commands.literal("version") + root.then(Commands.literal("version").requires(READ_ONLY) .executes((CommandContext context) -> IrisModdedCommands.version(context.getSource()))); - root.then(Commands.literal("info").requires(GATE) + root.then(Commands.literal("info").requires(READ_ONLY) .executes((CommandContext context) -> IrisModdedCommands.info(context.getSource(), null)) .then(Commands.argument("dimension", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.DIMENSION_NAMES) .executes((CommandContext context) -> IrisModdedCommands.info(context.getSource(), StringArgumentType.getString(context, "dimension"))))); - root.then(ModdedWhatCommands.tree()); + // ModdedWhatCommands.tree() gates itself at LEVEL_GAMEMASTERS; the /iris what overlays are read-only, + // so the root builder relaxes the whole subtree here instead of forking that file. + root.then(ModdedWhatCommands.tree().requires(READ_ONLY)); root.then(teleportTree("teleport")); root.then(teleportTree("tp")); @@ -69,9 +86,9 @@ final class ModdedCommandTree { root.then(Commands.literal("reload").requires(GATE) .executes((CommandContext context) -> IrisModdedCommands.reload(context.getSource()))); - root.then(Commands.literal("height").requires(GATE) + root.then(Commands.literal("height").requires(READ_ONLY) .executes((CommandContext context) -> IrisModdedCommands.height(context.getSource()))); - root.then(Commands.literal("worlds").requires(GATE) + root.then(Commands.literal("worlds").requires(READ_ONLY) .executes((CommandContext context) -> IrisModdedCommands.info(context.getSource(), null))); root.then(Commands.literal("accesslist").requires(GATE) .executes((CommandContext context) -> IrisModdedCommands.info(context.getSource(), null))); @@ -162,7 +179,7 @@ final class ModdedCommandTree { } private static LiteralArgumentBuilder helpTree() { - return Commands.literal("help") + return Commands.literal("help").requires(READ_ONLY) .executes((CommandContext context) -> ModdedCommandHelp.send(context.getSource(), "")) .then(Commands.argument("section", StringArgumentType.greedyString()) .executes((CommandContext context) -> ModdedCommandHelp.send(context.getSource(), StringArgumentType.getString(context, "section")))); @@ -202,7 +219,7 @@ final class ModdedCommandTree { } private static LiteralArgumentBuilder metricsTree(String name) { - return Commands.literal(name).requires(GATE) + return Commands.literal(name).requires(READ_ONLY) .executes((CommandContext context) -> IrisModdedCommands.metrics(context.getSource())); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java index 6073be3b4..454f587b0 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java @@ -23,6 +23,7 @@ import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedServerLevels; import art.arcane.volmlib.util.collection.KList; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; @@ -107,7 +108,7 @@ public final class ModdedDatapackCommands { MinecraftServer server = source.getServer(); int irisLevels = 0; int mismatches = 0; - for (ServerLevel level : server.getAllLevels()) { + for (ServerLevel level : ModdedServerLevels.levels(server)) { if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator irisGenerator)) { continue; } @@ -150,7 +151,7 @@ public final class ModdedDatapackCommands { private static int install(CommandSourceStack source) { MinecraftServer server = source.getServer(); List written = new ArrayList<>(); - for (ServerLevel level : server.getAllLevels()) { + for (ServerLevel level : ModdedServerLevels.levels(server)) { if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator irisGenerator)) { continue; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java index 709686fbb..cf070912a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java @@ -26,6 +26,8 @@ import art.arcane.iris.modded.ModdedBlockResolution; import art.arcane.iris.modded.ModdedBlockState; import art.arcane.iris.modded.ModdedTileData; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.matter.Matter; import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.block.Block; @@ -43,6 +45,7 @@ import java.util.Map; final class ModdedObjectPlacer implements IObjectPlacer { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final int DEFAULT_FLUID_HEIGHT = 63; private final ServerLevel level; private final Engine engine; @@ -116,9 +119,21 @@ final class ModdedObjectPlacer implements IObjectPlacer { return false; } + /** + * Mantle Y is relative to the world minimum height while this placer works in absolute world Y, so shift + * before the lookup. Only answers from an already loaded mantle chunk: a hand placed object can sit + * anywhere, and loading a mantle chunk to answer a carve probe would generate terrain as a side effect. + */ @Override public boolean isCarved(int x, int y, int z) { - return false; + if (engine == null) { + return false; + } + Mantle mantle = engine.getMantle().getMantle(); + if (mantle.isClosed() || !mantle.isChunkLoaded(x >> 4, z >> 4)) { + return false; + } + return engine.getMantle().isCarved(x, y - engine.getWorld().minHeight(), z); } @Override @@ -126,14 +141,24 @@ final class ModdedObjectPlacer implements IObjectPlacer { return ModdedBlockResolution.isSolid(level.getBlockState(new BlockPos(x, y, z))); } + /** + * Engine height stream against the dimension fluid height, both engine relative, so no shift here. Needs a + * ready complex; the placer also runs from commands against levels that never bound an engine. + */ @Override public boolean isUnderwater(int x, int z) { - return false; + return engine != null && engine.getComplex() != null && engine.getMantle().isUnderwater(x, z); } + /** + * IrisDimension fluid height is engine relative while this placer works in absolute world Y, so shift it + * up by the engine minimum before handing it to object placement. + */ @Override public int getFluidHeight() { - return 63; + return engine == null + ? DEFAULT_FLUID_HEIGHT + : engine.getMinHeight() + engine.getDimension().getFluidHeight(); } @Override diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java index 1d24dce73..272e39527 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java @@ -23,8 +23,10 @@ import art.arcane.iris.core.pregenerator.PregenListener; import art.arcane.iris.core.pregenerator.PregenMantleBackpressure; import art.arcane.iris.core.pregenerator.PregeneratorMethod; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.modded.ModdedGenPool; import art.arcane.volmlib.util.mantle.runtime.Mantle; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.dedicated.DedicatedServer; import net.minecraft.server.level.ChunkResult; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.TicketType; @@ -32,9 +34,9 @@ import net.minecraft.world.level.ChunkPos; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.lang.reflect.Field; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.Semaphore; import java.util.concurrent.ThreadPoolExecutor; @@ -52,7 +54,6 @@ public final class ModdedPregenMethod implements PregeneratorMethod { private static final long ADAPTIVE_RECOVERY_INTERVAL = 64L; private static final long FINAL_SAVE_TIMEOUT_MILLIS = 10_000L; private static final long FINAL_SAVE_POLL_MILLIS = 50L; - private static final boolean PARALLEL_CHUNK_SYSTEM = detectParallelChunkSystem(); private final ServerLevel level; private final Engine engine; @@ -70,8 +71,10 @@ public final class ModdedPregenMethod implements PregeneratorMethod { private final AtomicBoolean finalSaveDeferred = new AtomicBoolean(false); private final AtomicBoolean finalSaveCompleted = new AtomicBoolean(false); private final AtomicReference queuedFinalSave = new AtomicReference<>(); + private final AtomicBoolean stallHintLogged = new AtomicBoolean(false); private final int timeoutSeconds; private final PregenMantleBackpressure backpressure; + private final PauseWhenEmptyGuard pauseGuard; public ModdedPregenMethod(ServerLevel level, Engine engine) { this(level, engine, false); @@ -81,6 +84,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod { this.level = level; this.engine = engine; this.sync = sync; + this.pauseGuard = new PauseWhenEmptyGuard(level.getServer()); IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen(); this.maxInFlight = Math.max(8, pregen.getModdedPregenInFlight()); this.minInFlight = Math.max(4, Math.min(16, maxInFlight / 4)); @@ -99,33 +103,38 @@ public final class ModdedPregenMethod implements PregeneratorMethod { @Override public void init() { - LOGGER.info("Iris modded pregen init: dim={} mode={} inFlightCap={} timeout={}s workerPool={} parallelChunkSystem={}", + pauseGuard.suspend(); + LOGGER.info("Iris modded pregen init: dim={} mode={} inFlightCap={} timeout={}s workerPool={} chunkSystem={}", level.dimension().identifier(), sync ? "sync" : "async", sync ? 1 : maxInFlight, timeoutSeconds, describeWorkerPool(), - PARALLEL_CHUNK_SYSTEM ? "yes" : "no"); - if (!sync && !PARALLEL_CHUNK_SYSTEM) { + ModdedGenPool.describeChunkSystem()); + if (!sync && !ModdedGenPool.parallelChunkSystem()) { LOGGER.info("Iris pregen note: this loader uses the vanilla main-thread chunk system, which caps pregen throughput. For Bukkit-level speed on Fabric install C2ME (Concurrent Chunk Management Engine); on servers use Paper."); } } @Override public void close() { - if (!sync) { - try { - semaphore.tryAcquire(maxInFlight, 5, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + try { + if (!sync) { + try { + semaphore.tryAcquire(maxInFlight, 5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } } + LOGGER.info("Iris modded pregen done: dim={} completed={} peakInFlight={} finalLimit={}", + level.dimension().identifier(), completed.get(), inFlightPeak.get(), adaptiveLimit.get()); + if (deferFinalSaveIfRequested()) { + return; + } + saveLevel(true); + } finally { + pauseGuard.restore(); } - LOGGER.info("Iris modded pregen done: dim={} completed={} peakInFlight={} finalLimit={}", - level.dimension().identifier(), completed.get(), inFlightPeak.get(), adaptiveLimit.get()); - if (deferFinalSaveIfRequested()) { - return; - } - saveLevel(true); } @Override @@ -337,6 +346,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod { } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (TimeoutException | ExecutionException e) { + if (e instanceof TimeoutException) { + noteStallHint(); + } LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, e.toString()); listener.onChunkFailed(x, z); } finally { @@ -413,11 +425,22 @@ public final class ModdedPregenMethod implements PregeneratorMethod { } private void onTimeout() { + noteStallHint(); if (timeoutStreak.incrementAndGet() % ADAPTIVE_TIMEOUT_STEP == 0) { adjustAdaptiveLimit(-1); } } + /** + * First timeout of a job explains itself if the server is able to stop ticking under us. Without + * this a paused server just produces a wall of identical chunk timeouts. + */ + private void noteStallHint() { + if (stallHintLogged.compareAndSet(false, true)) { + pauseGuard.logStallHint(); + } + } + private void onSuccess() { int streak = timeoutStreak.get(); if (streak > 0) { @@ -455,56 +478,171 @@ public final class ModdedPregenMethod implements PregeneratorMethod { private void cleanupMantleChunk(int x, int z) { try { engine.getMantle().forceCleanupChunk(x, z); - } catch (Throwable ignored) { + } catch (Throwable e) { + LOGGER.debug("Iris pregen mantle cleanup skipped for {},{}: {}", x, z, e.toString()); } } private String describeWorkerPool() { - try { - Field field = MinecraftServer.class.getDeclaredField("executor"); - field.setAccessible(true); - Object exec = field.get(level.getServer()); - if (exec == null) { - return "unknown"; - } - if (exec instanceof ThreadPoolExecutor tpe) { - return "ThreadPoolExecutor(core=" + tpe.getCorePoolSize() + ",max=" + tpe.getMaximumPoolSize() + ")"; - } - if (exec instanceof ForkJoinPool fjp) { - return "ForkJoinPool(parallelism=" + fjp.getParallelism() + ")"; - } - return exec.getClass().getSimpleName(); - } catch (Throwable e) { + Executor exec = level.getServer().executor; + if (exec == null) { return "unknown"; } + if (exec instanceof ThreadPoolExecutor tpe) { + return "ThreadPoolExecutor(core=" + tpe.getCorePoolSize() + ",max=" + tpe.getMaximumPoolSize() + ")"; + } + if (exec instanceof ForkJoinPool fjp) { + return "ForkJoinPool(parallelism=" + fjp.getParallelism() + ")"; + } + return exec.getClass().getSimpleName(); } private static Throwable unwrap(Throwable error) { return error != null && error.getCause() != null ? error.getCause() : error; } - private static boolean detectParallelChunkSystem() { - String[] markers = { - "com.ishland.c2me.base.ModProperties", - "com.ishland.c2me.base.common.config.C2MEConfig", - "com.ishland.c2me.opts.chunkio.ModProperties", - "ca.spottedleaf.moonrise.common.util.MoonriseCommon" - }; - for (String marker : markers) { - try { - Class.forName(marker, false, ModdedPregenMethod.class.getClassLoader()); - return true; - } catch (Throwable ignored) { - } - } - return false; - } - @Override public Mantle getMantle() { return engine.getMantle().getMantle(); } + /** + * A dedicated server with {@code pause-when-empty-seconds > 0} returns from + * {@code MinecraftServer#tickServer} before {@code tickChildren} once it has been empty for that + * long (26.2 only keeps {@code tickConnection} plus the task/chunk-poll window alive). That + * freezes every per-tick Iris service - world manager, scheduler, protocol sync, pregen HUD - and + * the loader's own generation hooks for the whole job, and console pregen on a default + * server.properties is always empty. The guard zeroes the setting for the duration of the job + * through the vanilla public accessors + * ({@code DedicatedServer#pauseWhenEmptySeconds}/{@code #setPauseWhenEmptySeconds}, both widened + * to public by Mojang for the management API) and restores the previous value on completion or + * abort. No reflection and no access widener, identical on all three loaders. An integrated + * (singleplayer) server never pauses on empty - {@code MinecraftServer#pauseWhenEmptySeconds} + * returns 0 there - so it is skipped silently. + * + *

A crash or a kill during the job would otherwise leave the setting at 0 for the rest of the install, + * so suspending also arms a JVM shutdown hook that restores the previous value. The hook and + * {@link #restore()} share the same atomic, so whichever runs first wins and the other is a no-op; a + * normal restore also unregisters the hook. The setting is only ever restored in memory - nothing rewrites + * server.properties, so operator edits made during the job survive. + */ + private static final class PauseWhenEmptyGuard { + private static final int NOT_SUSPENDED = -1; + + private final MinecraftServer server; + private final AtomicInteger suspendedFrom = new AtomicInteger(NOT_SUSPENDED); + private final AtomicReference crashRestoreHook = new AtomicReference<>(); + + private PauseWhenEmptyGuard(MinecraftServer server) { + this.server = server; + } + + private void suspend() { + if (!(server instanceof DedicatedServer dedicated)) { + return; + } + int current; + try { + current = dedicated.pauseWhenEmptySeconds(); + } catch (Throwable e) { + LOGGER.warn("Iris pregen could not read pause-when-empty-seconds: {}", e.toString()); + return; + } + if (current <= 0) { + return; + } + try { + dedicated.setPauseWhenEmptySeconds(0); + } catch (Throwable e) { + refuse(current, e.toString()); + return; + } + int applied; + try { + applied = dedicated.pauseWhenEmptySeconds(); + } catch (Throwable e) { + refuse(current, e.toString()); + return; + } + if (applied != 0) { + refuse(current, "still " + applied + "s after the write"); + return; + } + suspendedFrom.set(current); + armCrashRestore(); + LOGGER.info("Iris pregen: suspending pause-when-empty (was {}s), restored when the job ends", current); + } + + private void restore() { + disarmCrashRestore(); + restoreOnce("restored"); + } + + private void restoreOnce(String what) { + int previous = suspendedFrom.getAndSet(NOT_SUSPENDED); + if (previous == NOT_SUSPENDED || !(server instanceof DedicatedServer dedicated)) { + return; + } + try { + dedicated.setPauseWhenEmptySeconds(previous); + LOGGER.info("Iris pregen: {} pause-when-empty ({}s)", what, previous); + } catch (Throwable e) { + LOGGER.error("Iris pregen could not restore pause-when-empty-seconds={}: {}. Set pause-when-empty-seconds={} in server.properties.", + previous, e.toString(), previous); + } + } + + private void armCrashRestore() { + Thread hook = new Thread(() -> restoreOnce("restored on shutdown"), "iris-pregen-pause-restore"); + if (!crashRestoreHook.compareAndSet(null, hook)) { + return; + } + try { + Runtime.getRuntime().addShutdownHook(hook); + } catch (IllegalStateException shuttingDown) { + crashRestoreHook.compareAndSet(hook, null); + } + } + + private void disarmCrashRestore() { + Thread hook = crashRestoreHook.getAndSet(null); + if (hook == null) { + return; + } + try { + Runtime.getRuntime().removeShutdownHook(hook); + } catch (IllegalStateException shuttingDown) { + // Already inside shutdown; the hook itself restores the value. + } + } + + /** + * True when the server can still stop ticking under a running job. + */ + private boolean pauseStillArmed() { + if (suspendedFrom.get() != NOT_SUSPENDED || !(server instanceof DedicatedServer dedicated)) { + return false; + } + try { + return dedicated.pauseWhenEmptySeconds() > 0 && server.getPlayerCount() == 0; + } catch (Throwable e) { + return false; + } + } + + private void logStallHint() { + if (!pauseStillArmed()) { + return; + } + LOGGER.error("Iris pregen is timing out on an empty server while pause-when-empty-seconds is active: the paused server stops ticking. Set pause-when-empty-seconds=0 in server.properties, or keep a player online while pregenerating."); + } + + private void refuse(int current, String reason) { + LOGGER.error("Iris pregen could not suspend pause-when-empty-seconds={} ({}). The server stops ticking once empty, which stalls pregen: set pause-when-empty-seconds=0 in server.properties, or keep a player online while pregenerating.", + current, reason); + } + } + private static final class FinalSaveRequest { private final CompletableFuture completion = new CompletableFuture<>(); private final AtomicBoolean active = new AtomicBoolean(true); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java index f26339ff7..8028d4d59 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java @@ -58,6 +58,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -66,6 +67,8 @@ import art.arcane.iris.core.localization.ModdedCommandMessages; public final class ModdedRegen { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final int APPLY_AHEAD = 8; + private static final long CHUNK_SLOT_TIMEOUT_MILLIS = 120000L; + private static final long FINAL_APPLY_TIMEOUT_MILLIS = 300000L; private static final AtomicBoolean ACTIVE = new AtomicBoolean(false); private final CommandSourceStack source; @@ -139,6 +142,7 @@ public final class ModdedRegen { private int regenerate(List targets) throws InterruptedException { Semaphore inFlight = new Semaphore(APPLY_AHEAD); CountDownLatch allApplied = new CountDownLatch(targets.size()); + AtomicBoolean aborted = new AtomicBoolean(false); AtomicInteger completed = new AtomicInteger(); AtomicInteger applied = new AtomicInteger(); int total = targets.size(); @@ -149,9 +153,21 @@ public final class ModdedRegen { for (int[] target : targets) { int chunkX = target[0]; int chunkZ = target[1]; - inFlight.acquire(); + if (!inFlight.tryAcquire(CHUNK_SLOT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) { + aborted.set(true); + LOGGER.error("Iris regen aborted: chunk {},{} waited {}ms for an apply slot ({}/{} done)", + chunkX, chunkZ, CHUNK_SLOT_TIMEOUT_MILLIS, completed.get(), total); + fail("Regen aborted: apply pipeline stalled at " + completed.get() + "/" + total + " chunk(s)"); + break; + } MultiBurst.burst.lazy(() -> { long chunkStart = M.ms(); + if (aborted.get()) { + completed.incrementAndGet(); + inFlight.release(); + allApplied.countDown(); + return; + } ModdedBlockBuffer blocks = new ModdedBlockBuffer(height, air); Hunk biomes = Hunk.newArrayHunk(16, height, 16); try { @@ -167,6 +183,9 @@ public final class ModdedRegen { server.execute(() -> { boolean success = false; try { + if (aborted.get()) { + return; + } apply(chunkX, chunkZ, blocks, biomes); success = true; applied.incrementAndGet(); @@ -185,7 +204,18 @@ public final class ModdedRegen { }); } - allApplied.await(); + if (aborted.get()) { + // Targets were never submitted, so the latch can no longer reach zero; in-flight tasks + // observe the abort flag and release themselves. + return applied.get(); + } + if (!allApplied.await(FINAL_APPLY_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) { + aborted.set(true); + long outstanding = allApplied.getCount(); + LOGGER.error("Iris regen aborted: {} of {} chunk(s) did not finish within {}ms", + outstanding, total, FINAL_APPLY_TIMEOUT_MILLIS); + fail("Regen aborted: " + outstanding + " of " + total + " chunk(s) never finished"); + } return applied.get(); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java index fe9e32445..4be4d8d5d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedWorldCommands.java @@ -29,6 +29,7 @@ import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedModConfig; import art.arcane.iris.modded.ModdedPackInstaller; import art.arcane.iris.modded.ModdedPrimaryWorldRouter; +import art.arcane.iris.modded.ModdedServerLevels; import art.arcane.iris.modded.ModdedStartup; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.LiteralArgumentBuilder; @@ -436,7 +437,7 @@ public final class ModdedWorldCommands { private static int status(CommandSourceStack source) { MinecraftServer server = source.getServer(); int loaded = 0; - for (ServerLevel level : server.getAllLevels()) { + for (ServerLevel level : ModdedServerLevels.levels(server)) { if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator) { loaded++; IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_WORLD_COMMANDS_LOADED_IRIS_LEVEL_PACK_DIMENSION, MessageArgument.untrusted("value", level.dimension().identifier()), MessageArgument.untrusted("value2", generator.activePack()), MessageArgument.untrusted("value3", generator.activeDimensionKey()))); @@ -466,7 +467,7 @@ public final class ModdedWorldCommands { private static List loadedIrisDimensions(MinecraftServer server) { List dimensions = new ArrayList<>(); - for (ServerLevel level : server.getAllLevels()) { + for (ServerLevel level : ModdedServerLevels.levels(server)) { if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) { dimensions.add(level.dimension().identifier().toString()); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/EntityPersistenceMixin.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/EntityPersistenceMixin.java index adf8c27b8..d3972a3fc 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/EntityPersistenceMixin.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/EntityPersistenceMixin.java @@ -1,6 +1,7 @@ package art.arcane.iris.modded.mixin; import art.arcane.iris.modded.ModdedEntityPersistence; +import art.arcane.iris.modded.ModdedMixinFlags; import net.minecraft.world.entity.Entity; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -11,6 +12,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; public abstract class EntityPersistenceMixin { @Inject(method = "shouldBeSaved", at = @At("RETURN"), cancellable = true) private void iris$applyGeneratedPersistence(CallbackInfoReturnable info) { + ModdedMixinFlags.markEntityPersistence(); Entity entity = (Entity) (Object) this; info.setReturnValue(ModdedEntityPersistence.shouldSave(entity, info.getReturnValue())); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/LivingEntityLootMixin.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/LivingEntityLootMixin.java index ba96edbe9..ac6f63701 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/LivingEntityLootMixin.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/LivingEntityLootMixin.java @@ -1,6 +1,7 @@ package art.arcane.iris.modded.mixin; import art.arcane.iris.modded.ModdedDeathLoot; +import art.arcane.iris.modded.ModdedMixinFlags; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.damagesource.DamageSource; import net.minecraft.world.entity.LivingEntity; @@ -13,6 +14,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; public abstract class LivingEntityLootMixin { @Inject(method = "dropFromLootTable(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/damagesource/DamageSource;Z)V", at = @At("HEAD"), cancellable = true) private void iris$replaceBaseLoot(ServerLevel level, DamageSource damageSource, boolean playerKilled, CallbackInfo info) { + ModdedMixinFlags.markLivingEntityLoot(); if (ModdedDeathLoot.replaceBaseLoot((LivingEntity) (Object) this)) { info.cancel(); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/MobAwarenessMixin.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/MobAwarenessMixin.java index 72d92e1d4..e3e02f8d4 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/MobAwarenessMixin.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/MobAwarenessMixin.java @@ -1,6 +1,7 @@ package art.arcane.iris.modded.mixin; import art.arcane.iris.modded.ModdedEntityAwareness; +import art.arcane.iris.modded.ModdedMixinFlags; import net.minecraft.world.entity.Mob; import net.minecraft.world.entity.ai.goal.FloatGoal; import net.minecraft.world.entity.ai.goal.WrappedGoal; @@ -21,6 +22,7 @@ public abstract class MobAwarenessMixin { shift = At.Shift.AFTER), cancellable = true) private void iris$tickUnawareMob(CallbackInfo info) { + ModdedMixinFlags.markMobAwareness(); Mob mob = (Mob) (Object) this; if (ModdedEntityAwareness.isAware(mob)) { return; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java index d448eedfa..92fb26767 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java @@ -27,6 +27,7 @@ import art.arcane.iris.engine.object.TileData; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedBlockResolution; import art.arcane.iris.modded.ModdedLootApplier; +import art.arcane.iris.modded.ModdedServerLevels; import art.arcane.iris.modded.ModdedTileData; import art.arcane.iris.modded.api.ModdedCustomContentRegistry; import art.arcane.iris.spi.IrisLogging; @@ -100,7 +101,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { return; } - for (ServerLevel level : server.getAllLevels()) { + for (ServerLevel level : ModdedServerLevels.levels(server)) { if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator)) { continue; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEntitySpawnService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEntitySpawnService.java index 4f824e826..8678b15a7 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEntitySpawnService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEntitySpawnService.java @@ -21,6 +21,7 @@ package art.arcane.iris.modded.service; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineWorldManager; import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedServerLevels; import art.arcane.iris.modded.ModdedWorldManager; import art.arcane.iris.spi.IrisLogging; import net.minecraft.server.MinecraftServer; @@ -37,7 +38,7 @@ public final class ModdedEntitySpawnService implements ModdedTickableService { @Override public void onServerTick(MinecraftServer server) { - for (ServerLevel level : server.getAllLevels()) { + for (ServerLevel level : ModdedServerLevels.levels(server)) { if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator)) { continue; } diff --git a/adapters/modded-common/src/main/resources/assets/irisworldgen/icon.png b/adapters/modded-common/src/main/resources/assets/irisworldgen/icon.png new file mode 100644 index 000000000..aadf45975 Binary files /dev/null and b/adapters/modded-common/src/main/resources/assets/irisworldgen/icon.png differ diff --git a/adapters/modded-common/src/main/resources/irisworldgen.client.mixins.json b/adapters/modded-common/src/main/resources/irisworldgen.client.mixins.json index c75511212..5140fdc9e 100644 --- a/adapters/modded-common/src/main/resources/irisworldgen.client.mixins.json +++ b/adapters/modded-common/src/main/resources/irisworldgen.client.mixins.json @@ -2,7 +2,7 @@ "required": true, "minVersion": "0.8", "package": "art.arcane.iris.client.mixin", - "compatibilityLevel": "JAVA_25", + "compatibilityLevel": "JAVA_21", "client": [ "IrisWorldOpenFlowsMixin", "IrisWorldTypeEntryMixin" diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/client/IrisClientSessionTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/client/IrisClientSessionTest.java index ef6f9f8bb..d238a5c04 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/client/IrisClientSessionTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/client/IrisClientSessionTest.java @@ -53,5 +53,56 @@ public class IrisClientSessionTest { IrisProtocol.PROTOCOL_VERSION + 1, 0L, "Iris", true)); assertEquals(IrisClientSession.State.INCOMPATIBLE, session.state()); + assertEquals(IrisProtocol.PROTOCOL_VERSION + 1, session.serverProtocolVersion()); + assertEquals("Iris", session.serverBrand()); + } + + @Test + public void helloWithNoSinkStillRetriesAndResolvesToUnsupported() { + AtomicLong clock = new AtomicLong(); + IrisClientSession session = new IrisClientSession(clock::get); + + // No bind: the loader has not wired the channel yet. Without arming the retry clock on this path, + // nextHelloAt stayed at Long.MAX_VALUE, tick() returned immediately forever and the UI never left + // "connecting". + session.sendHello(); + assertEquals(IrisClientSession.State.AWAITING_HELLO, session.state()); + + for (int attempt = 0; attempt < 5; attempt++) { + clock.addAndGet(2_000L); + session.tick(); + } + + assertEquals(IrisClientSession.State.UNSUPPORTED, session.state()); + } + + @Test + public void sinkBoundLateStillCompletesTheHandshake() { + AtomicLong clock = new AtomicLong(); + AtomicInteger frames = new AtomicInteger(); + IrisClientSession session = new IrisClientSession(clock::get); + + session.sendHello(); + assertEquals(0, frames.get()); + + session.bind(frame -> frames.incrementAndGet()); + clock.addAndGet(2_000L); + session.tick(); + + assertEquals(1, frames.get()); + assertEquals(IrisClientSession.State.AWAITING_HELLO, session.state()); + } + + @Test + public void resetClearsTheServerVersion() { + IrisClientSession session = new IrisClientSession(); + session.onServerHello(new IrisMessage.ServerHello( + IrisProtocol.PROTOCOL_VERSION, IrisProtocol.CAPABILITY_VISION, "Iris", true)); + assertTrue(session.isReady()); + + session.reset(); + + assertEquals(IrisClientSession.State.IDLE, session.state()); + assertEquals(0, session.serverProtocolVersion()); } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/client/IrisClientWireBoundsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/client/IrisClientWireBoundsTest.java new file mode 100644 index 000000000..ed0af65d2 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/client/IrisClientWireBoundsTest.java @@ -0,0 +1,160 @@ +package art.arcane.iris.client; + +import art.arcane.iris.core.protocol.IrisTileEncoder; +import art.arcane.iris.spi.protocol.IrisMessage; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Every structure here is filled from the wire, so its size is a dial the server holds. Caps mean a hostile or + * merely buggy server can waste bandwidth but not the client's heap. + */ +public class IrisClientWireBoundsTest { + @Test + public void markerTilesBeyondTheCapEvictTheOldest() { + IrisClientMarkers markers = new IrisClientMarkers(); + int overflow = IrisClientMarkers.MAX_TILES + 8; + for (int tileX = 0; tileX < overflow; tileX++) { + markers.onMarkers(new IrisMessage.VisionMarkers(tileX, 0, 0, + List.of(new IrisMessage.VisionMarkers.Marker(tileX, 0, 0, "m" + tileX)))); + } + + assertEquals(IrisClientMarkers.MAX_TILES, markers.trackedTiles()); + assertNull("the oldest marker tile must be gone", markers.forTile(new IrisTileKey(0, 0, 0))); + assertNotNull("the newest marker tile must be retained", markers.forTile(new IrisTileKey(overflow - 1, 0, 0))); + } + + @Test + public void markerTileIsReplacedNotAccumulated() { + IrisClientMarkers markers = new IrisClientMarkers(); + IrisTileKey key = new IrisTileKey(4, 5, 1); + markers.onMarkers(new IrisMessage.VisionMarkers(4, 5, 1, + List.of(new IrisMessage.VisionMarkers.Marker(1, 1, 0, "first"), + new IrisMessage.VisionMarkers.Marker(2, 2, 0, "second")))); + markers.onMarkers(new IrisMessage.VisionMarkers(4, 5, 1, + List.of(new IrisMessage.VisionMarkers.Marker(3, 3, 0, "third")))); + + assertEquals(1, markers.forTile(key).size()); + assertEquals(1, markers.trackedTiles()); + } + + @Test + public void pregenJobsBeyondTheCapEvictTheOldest() { + IrisClientPregenState pregen = new IrisClientPregenState(); + int overflow = IrisClientPregenState.MAX_JOBS + 5; + for (int jobId = 0; jobId < overflow; jobId++) { + pregen.onProgress(progress(jobId)); + } + + assertEquals(IrisClientPregenState.MAX_JOBS, pregen.trackedJobs()); + assertEquals(Long.valueOf(overflow - 1L), pregen.activeJobId()); + assertNotNull(pregen.active()); + } + + @Test + public void pregenPanelGoesStaleThenExpiresWithoutUpdates() { + AtomicLong clock = new AtomicLong(10_000L); + IrisClientPregenState pregen = new IrisClientPregenState(clock::get); + pregen.onProgress(progress(1L)); + + assertEquals(0L, pregen.activeAgeMillis()); + assertFalse(pregen.activeStale()); + assertFalse(pregen.activeExpired()); + + clock.addAndGet(IrisClientPregenState.STALE_AFTER_MILLIS); + assertTrue(pregen.activeStale()); + assertFalse(pregen.activeExpired()); + + clock.addAndGet(IrisClientPregenState.EXPIRE_AFTER_MILLIS); + assertTrue(pregen.activeExpired()); + + pregen.onProgress(progress(1L)); + assertFalse("a fresh frame must revive the panel", pregen.activeStale()); + } + + @Test + public void noActiveJobIsNeitherStaleNorExpired() { + AtomicLong clock = new AtomicLong(0L); + IrisClientPregenState pregen = new IrisClientPregenState(clock::get); + + assertEquals(-1L, pregen.activeAgeMillis()); + assertFalse(pregen.activeStale()); + assertFalse(pregen.activeExpired()); + + pregen.onProgress(progress(7L)); + pregen.onEnd(7L); + + assertNull(pregen.active()); + assertEquals(-1L, pregen.activeAgeMillis()); + assertFalse(pregen.activeExpired()); + } + + @Test + public void tileCacheKeepsTheDefaultBudgetForASmallViewport() { + assertEquals(IrisClientTileCache.DEFAULT_MAX_CACHED_TILES, retainedTiles(4)); + } + + @Test + public void tileCacheGrowsForALargeViewportButNotPastTheCeiling() { + assertEquals(IrisClientTileCache.ABSOLUTE_MAX_CACHED_TILES, + retainedTiles(IrisClientTileCache.ABSOLUTE_MAX_CACHED_TILES * 4)); + } + + @Test + public void malformedTileIsDroppedAndCountedWithoutPoisoningTheCache() { + IrisClientTileCache cache = new IrisClientTileCache(frame -> { + }, () -> 0L); + byte[] garbage = new byte[32]; + Arrays.fill(garbage, (byte) 0x7F); + + cache.onVisionTile(new IrisMessage.VisionTile(0, 0, 0, 1, 0, 1, garbage)); + + assertEquals(1L, cache.droppedMalformedCount()); + assertNull(cache.get(new IrisTileKey(0, 0, 0))); + + cache.onVisionTile(flatTile(0)); + assertNotNull("a bad frame must not block later tiles", cache.get(new IrisTileKey(0, 0, 0))); + } + + /** + * Capacity is not exposed, so it is measured the only way that matters: how many tiles survive. Tiles go in + * through the real wire path, so each one is a decoded image, not a stub. + */ + private static int retainedTiles(int viewportTiles) { + IrisClientTileCache cache = new IrisClientTileCache(frame -> { + }, () -> 0L); + cache.ensureCapacity(viewportTiles); + int expected = Math.max(IrisClientTileCache.DEFAULT_MAX_CACHED_TILES, + Math.min(IrisClientTileCache.ABSOLUTE_MAX_CACHED_TILES, viewportTiles)); + int inserted = expected + 8; + for (int tileX = 0; tileX < inserted; tileX++) { + cache.onVisionTile(flatTile(tileX)); + } + int retained = 0; + for (int tileX = 0; tileX < inserted; tileX++) { + if (cache.get(new IrisTileKey(tileX, 0, 0)) != null) { + retained++; + } + } + return retained; + } + + private static IrisMessage.VisionTile flatTile(int tileX) { + int[] pixels = new int[4]; + Arrays.fill(pixels, 0xFF102030); + return new IrisMessage.VisionTile(tileX, 0, 0, 1, 0, 1, IrisTileEncoder.encodePixels(pixels, 2, 2)); + } + + private static IrisMessage.PregenProgress progress(long jobId) { + return new IrisMessage.PregenProgress(jobId, 10L, 100L, 5.0D, 1000L, IrisMessage.PregenProgress.STATE_RUNNING); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/client/IrisTileAssemblerAdversarialTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/client/IrisTileAssemblerAdversarialTest.java new file mode 100644 index 000000000..f35327dcc --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/client/IrisTileAssemblerAdversarialTest.java @@ -0,0 +1,193 @@ +package art.arcane.iris.client; + +import art.arcane.iris.core.protocol.IrisTileEncoder; +import art.arcane.iris.spi.protocol.IrisMessage; +import art.arcane.iris.spi.protocol.ProtocolException; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.zip.Deflater; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +/** + * The tile stream is attacker-controlled: any server a player joins picks the sequence, chunk index, chunk + * count and payload. Structurally impossible headers must be dropped without allocating from them, a complete + * set that decodes to garbage must raise {@link ProtocolException} rather than return a half-built image, and a + * deflate stream that cannot make progress must not spin the render thread. + */ +public class IrisTileAssemblerAdversarialTest { + private static final int SIZE = IrisTileEncoder.TILE_PIXELS; + private static final byte[] PAYLOAD = {1, 2, 3, 4}; + + @Test + public void impossibleChunkHeadersAreDropped() throws ProtocolException { + IrisTileAssembler assembler = new IrisTileAssembler(); + assertNull("zero chunk count", assembler.add(tile(0, 0, 1, 0, 0, PAYLOAD))); + assertNull("negative chunk count", assembler.add(tile(0, 0, 1, 0, -1, PAYLOAD))); + assertNull("chunk count beyond the largest legal tile", + assembler.add(tile(0, 0, 1, 0, IrisTileAssembler.MAX_CHUNK_COUNT + 1, PAYLOAD))); + assertNull("index at count", assembler.add(tile(0, 0, 1, 2, 2, PAYLOAD))); + assertNull("index beyond count", assembler.add(tile(0, 0, 1, 9, 2, PAYLOAD))); + assertNull("negative index", assembler.add(tile(0, 0, 1, -1, 2, PAYLOAD))); + assertNull("missing payload", assembler.add(tile(0, 0, 1, 0, 1, null))); + } + + @Test + public void rejectedHeaderLeavesNoPartialBehind() throws ProtocolException { + IrisTileAssembler assembler = new IrisTileAssembler(); + assertNull(assembler.add(tile(0, 0, 1, 5, 2, PAYLOAD))); + + List good = IrisTileEncoder.splitIntoChunks(validBlob(), 0, 0, 0, 1); + assertNotNull("a rejected frame must not poison the tile slot", + IrisVisionTileRoundTripTest.assemble(assembler, good)); + } + + @Test + public void chunkCountFlipMidSetRestartsTheSet() throws ProtocolException { + IrisTileAssembler assembler = new IrisTileAssembler(); + // Two chunks promised at sequence 4, then the same sequence claims a single-chunk set. The stale + // half-set must be dropped rather than concatenated into the new one. + assertNull(assembler.add(tile(0, 0, 4, 0, 2, PAYLOAD))); + + List single = IrisTileEncoder.splitIntoChunks(validBlob(), 0, 0, 0, 4); + assertEquals("a flat tile must deflate into a single chunk", 1, single.size()); + assertNotNull(assembler.add(single.get(0))); + } + + @Test + public void staleSequenceChunksAreIgnoredAndTheNewerSetStillCompletes() throws ProtocolException { + IrisTileAssembler assembler = new IrisTileAssembler(); + byte[] blob = validBlob(); + byte[] head = Arrays.copyOfRange(blob, 0, blob.length / 2); + byte[] tail = Arrays.copyOfRange(blob, blob.length / 2, blob.length); + + assertNull(assembler.add(tile(0, 0, 9, 0, 2, head))); + assertNull("a chunk from an older sequence must not join the current set", + assembler.add(tile(0, 0, 8, 1, 2, tail))); + assertNotNull(assembler.add(tile(0, 0, 9, 1, 2, tail))); + } + + @Test + public void partialsBeyondTheCapAreEvicted() throws ProtocolException { + IrisTileAssembler assembler = new IrisTileAssembler(); + byte[] blob = validBlob(); + byte[] head = Arrays.copyOfRange(blob, 0, blob.length / 2); + byte[] tail = Arrays.copyOfRange(blob, blob.length / 2, blob.length); + + int overflow = IrisTileAssembler.MAX_PENDING_TILES + 1; + for (int tileX = 0; tileX < overflow; tileX++) { + assertNull(assembler.add(tile(tileX, 0, 1, 0, 2, head))); + } + // Tile 0 was the oldest partial and is gone, so its closing chunk opens a fresh incomplete set + // instead of finishing one. Retaining every partial forever would be the alternative. + assertNull("the oldest partial must have been evicted", assembler.add(tile(0, 0, 1, 1, 2, tail))); + assertNotNull("the newest partial must have survived", assembler.add(tile(overflow - 1, 0, 1, 1, 2, tail))); + } + + @Test + public void garbagePayloadRaisesProtocolException() { + IrisTileAssembler assembler = new IrisTileAssembler(); + byte[] garbage = new byte[64]; + Arrays.fill(garbage, (byte) 0x7F); + assertThrows(ProtocolException.class, () -> assembler.add(tile(0, 0, 1, 0, 1, garbage))); + } + + @Test + public void truncatedDeflateStreamRaisesProtocolExceptionInsteadOfSpinning() { + IrisTileAssembler assembler = new IrisTileAssembler(); + byte[] noisy = new byte[60_000]; + new Random(4242L).nextBytes(noisy); + byte[] full = deflate(noisy); + byte[] truncated = Arrays.copyOfRange(full, 0, full.length / 4); + // Zero-progress inflater: input exhausted, stream not finished. The old loop only broke out when + // finished/needsInput/needsDictionary happened to be set, so this shape spun forever on the render + // thread. + assertThrows(ProtocolException.class, () -> assembler.add(tile(0, 0, 1, 0, 1, truncated))); + } + + @Test + public void decompressionBombRaisesProtocolException() { + IrisTileAssembler assembler = new IrisTileAssembler(); + byte[] bomb = deflate(new byte[IrisTileCodec.MAX_DECODED_BYTES + 1024]); + assertThrows(ProtocolException.class, () -> assembler.add(tile(0, 0, 1, 0, 1, bomb))); + } + + @Test + public void impossibleHeaderFieldsRaiseProtocolException() { + IrisTileAssembler assembler = new IrisTileAssembler(); + assertThrows("zero width", ProtocolException.class, + () -> assembler.add(tile(0, 0, 1, 0, 1, deflate(header(0, SIZE, IrisTileCodec.MODE_RAW_RGB))))); + assertThrows("oversized height", ProtocolException.class, + () -> assembler.add(tile(1, 0, 1, 0, 1, deflate(header(SIZE, 4096, IrisTileCodec.MODE_RAW_RGB))))); + assertThrows("unknown pixel mode", ProtocolException.class, + () -> assembler.add(tile(2, 0, 1, 0, 1, deflate(header(SIZE, SIZE, 42))))); + } + + @Test + public void paletteIndexBeyondPaletteRaisesProtocolException() { + IrisTileAssembler assembler = new IrisTileAssembler(); + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(raw)) { + out.writeInt(2); + out.writeInt(2); + out.writeByte(IrisTileCodec.MODE_PALETTE); + out.writeInt(1); + out.writeByte(0); + out.writeByte(0); + out.writeByte(0); + for (int pixel = 0; pixel < 4; pixel++) { + out.writeByte(pixel == 3 ? 200 : 0); + } + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } + byte[] blob = deflate(raw.toByteArray()); + assertThrows(ProtocolException.class, () -> assembler.add(tile(0, 0, 1, 0, 1, blob))); + } + + private static IrisMessage.VisionTile tile(int tileX, int tileZ, int sequence, int chunkIndex, int chunkCount, byte[] data) { + return new IrisMessage.VisionTile(tileX, tileZ, 0, sequence, chunkIndex, chunkCount, data); + } + + /** A real encoder output for a flat tile: one chunk, palette mode, decodes cleanly. */ + private static byte[] validBlob() { + int[] pixels = new int[SIZE * SIZE]; + Arrays.fill(pixels, 0xFF204060); + return IrisTileEncoder.encodePixels(pixels, SIZE, SIZE); + } + + private static byte[] header(int width, int height, int mode) { + ByteArrayOutputStream raw = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(raw)) { + out.writeInt(width); + out.writeInt(height); + out.writeByte(mode); + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } + return raw.toByteArray(); + } + + private static byte[] deflate(byte[] input) { + Deflater deflater = new Deflater(Deflater.BEST_SPEED); + deflater.setInput(input); + deflater.finish(); + ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(64, input.length / 2)); + byte[] buffer = new byte[8192]; + while (!deflater.finished()) { + out.write(buffer, 0, deflater.deflate(buffer)); + } + deflater.end(); + return out.toByteArray(); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/client/IrisVisionTileRoundTripTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/client/IrisVisionTileRoundTripTest.java new file mode 100644 index 000000000..338d74a45 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/client/IrisVisionTileRoundTripTest.java @@ -0,0 +1,149 @@ +package art.arcane.iris.client; + +import art.arcane.iris.core.protocol.IrisTileEncoder; +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.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * End-to-end vision tile path: {@link IrisTileEncoder} on the server, the wire codec both ways, then + * {@link IrisTileAssembler} and {@link IrisTileCodec} on the client. Encoder and decoder live in different + * modules and share no code, so pixel equality is the only thing that proves they still agree - a palette + * write order change or a header field added on one side is otherwise silent. + */ +public class IrisVisionTileRoundTripTest { + private static final int SIZE = IrisTileEncoder.TILE_PIXELS; + private static final int OPAQUE = 0xFF000000; + + @Test + public void paletteTileRoundTripsToIdenticalPixels() throws ProtocolException { + int[] pixels = bandedPixels(16); + IrisTileImage decoded = roundTrip(pixels, 3, -4, 2, 7); + assertNotNull(decoded); + assertEquals(SIZE, decoded.width()); + assertEquals(SIZE, decoded.height()); + assertOpaqueRgbEquals(pixels, decoded.argb()); + } + + @Test + public void rawTileSplitsIntoChunksAndRoundTrips() throws ProtocolException { + int[] pixels = highEntropyPixels(); + byte[] blob = IrisTileEncoder.encodePixels(pixels, SIZE, SIZE); + List chunks = IrisTileEncoder.splitIntoChunks(blob, 0, 0, 0, 1); + assertTrue("a high-entropy tile must exceed one chunk to exercise reassembly", chunks.size() > 1); + for (IrisMessage.VisionTile chunk : chunks) { + assertTrue("chunk frame must fit the wire cap", + IrisMessageCodec.encode(chunk).length <= IrisProtocol.MAX_FRAME_BYTES); + } + + IrisTileImage decoded = assemble(new IrisTileAssembler(), overTheWire(chunks)); + assertNotNull(decoded); + assertOpaqueRgbEquals(pixels, decoded.argb()); + } + + @Test + public void chunksReassembleWhenDeliveredOutOfOrder() throws ProtocolException { + int[] pixels = highEntropyPixels(); + byte[] blob = IrisTileEncoder.encodePixels(pixels, SIZE, SIZE); + List chunks = new ArrayList<>(overTheWire(IrisTileEncoder.splitIntoChunks(blob, 5, 6, 1, 42))); + assertTrue(chunks.size() > 1); + Collections.reverse(chunks); + + IrisTileImage decoded = assemble(new IrisTileAssembler(), chunks); + assertNotNull(decoded); + assertOpaqueRgbEquals(pixels, decoded.argb()); + } + + @Test + public void incompleteChunkSetProducesNothing() throws ProtocolException { + int[] pixels = highEntropyPixels(); + byte[] blob = IrisTileEncoder.encodePixels(pixels, SIZE, SIZE); + List chunks = overTheWire(IrisTileEncoder.splitIntoChunks(blob, 1, 1, 0, 1)); + assertTrue(chunks.size() > 1); + + IrisTileAssembler assembler = new IrisTileAssembler(); + assertNull(assemble(assembler, chunks.subList(0, chunks.size() - 1))); + assertNotNull(assembler.add(chunks.get(chunks.size() - 1))); + } + + @Test + public void repeatedChunkDoesNotCompleteTheSetEarly() throws ProtocolException { + int[] pixels = highEntropyPixels(); + byte[] blob = IrisTileEncoder.encodePixels(pixels, SIZE, SIZE); + List chunks = overTheWire(IrisTileEncoder.splitIntoChunks(blob, 2, 2, 0, 1)); + assertTrue(chunks.size() > 1); + + IrisTileAssembler assembler = new IrisTileAssembler(); + for (int repeat = 0; repeat < chunks.size() + 2; repeat++) { + assertNull("a duplicated chunk must not count twice", assembler.add(chunks.get(0))); + } + } + + static IrisTileImage assemble(IrisTileAssembler assembler, List chunks) throws ProtocolException { + IrisTileImage assembled = null; + for (IrisMessage.VisionTile chunk : chunks) { + IrisTileImage produced = assembler.add(chunk); + if (produced != null) { + assembled = produced; + } + } + return assembled; + } + + /** Pushes each chunk through encode/decode so the test covers the wire form, not just the record. */ + private static List overTheWire(List chunks) { + List transported = new ArrayList<>(chunks.size()); + for (IrisMessage.VisionTile chunk : chunks) { + try { + transported.add((IrisMessage.VisionTile) IrisMessageCodec.decode(IrisMessageCodec.encode(chunk))); + } catch (ProtocolException rejected) { + throw new AssertionError("a chunk the encoder produced must survive the codec", rejected); + } + } + return transported; + } + + private static IrisTileImage roundTrip(int[] pixels, int tileX, int tileZ, int zoom, int sequence) throws ProtocolException { + byte[] blob = IrisTileEncoder.encodePixels(pixels, SIZE, SIZE); + return assemble(new IrisTileAssembler(), overTheWire(IrisTileEncoder.splitIntoChunks(blob, tileX, tileZ, zoom, sequence))); + } + + private static void assertOpaqueRgbEquals(int[] source, int[] decoded) { + assertEquals(source.length, decoded.length); + for (int index = 0; index < source.length; index++) { + assertEquals("pixel " + index, OPAQUE | source[index] & 0xFFFFFF, decoded[index]); + } + } + + /** Few enough distinct colours that the encoder picks palette mode. */ + private static int[] bandedPixels(int colors) { + int[] pixels = new int[SIZE * SIZE]; + for (int index = 0; index < pixels.length; index++) { + int band = index % colors; + pixels[index] = OPAQUE | band * 16 << 16 | band * 8 << 8 | band * 4; + } + return pixels; + } + + /** Enough distinct colours to force raw mode, and enough entropy that deflate cannot fold it into one chunk. */ + private static int[] highEntropyPixels() { + Random random = new Random(1337L); + int[] pixels = new int[SIZE * SIZE]; + for (int index = 0; index < pixels.length; index++) { + pixels[index] = OPAQUE | random.nextInt() & 0xFFFFFF; + } + return pixels; + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/InitialSpawnQueueTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/InitialSpawnQueueTest.java index 649ccb8b2..76aecdd4e 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/InitialSpawnQueueTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/InitialSpawnQueueTest.java @@ -105,6 +105,23 @@ public class InitialSpawnQueueTest { assertEquals(Long.valueOf(3L), queue.poll()); } + @Test + public void expireKeepsYoungerOffersWhenReleasingCapacity() { + AtomicLong now = new AtomicLong(); + InitialSpawnQueue queue = new InitialSpawnQueue(2, 100L, now::get); + queue.offer(1L); + now.set(60L); + queue.offer(2L); + + now.set(100L); + + assertTrue(queue.offer(3L)); + assertEquals(2, queue.size()); + assertEquals(Long.valueOf(2L), queue.poll()); + assertEquals(Long.valueOf(3L), queue.poll()); + assertNull(queue.poll()); + } + @Test public void expiredInFlightEntryCannotRetry() { AtomicLong now = new AtomicLong(); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/MainWorldServiceInstanceRootTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/MainWorldServiceInstanceRootTest.java new file mode 100644 index 000000000..4c9c2d746 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/MainWorldServiceInstanceRootTest.java @@ -0,0 +1,94 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class MainWorldServiceInstanceRootTest { + @Test + public void universeOptionIsReadInBothArgumentForms() { + assertEquals("worlds", MainWorldService.commandLineOption( + List.of("nogui", "--universe", "worlds"), "universe")); + assertEquals("worlds", MainWorldService.commandLineOption( + List.of("--universe=worlds", "nogui"), "universe")); + assertEquals("survival", MainWorldService.commandLineOption( + List.of("--universe", "worlds", "--world", "survival"), "world")); + } + + @Test + public void missingOrValuelessOptionsResolveToNull() { + assertNull(MainWorldService.commandLineOption(List.of("nogui"), "universe")); + assertNull(MainWorldService.commandLineOption(List.of(), "world")); + assertNull(MainWorldService.commandLineOption(List.of("nogui", "--world"), "world")); + } + + @Test + public void universeRootDefaultsToTheInstanceRootAndHonorsTheOption() throws IOException { + Path instanceRoot = Files.createTempDirectory("iris-instance"); + Path elsewhere = Files.createTempDirectory("iris-elsewhere"); + + assertEquals(instanceRoot, MainWorldService.universeRoot(instanceRoot, null)); + assertEquals(instanceRoot, MainWorldService.universeRoot(instanceRoot, "")); + assertEquals(instanceRoot.resolve("worlds"), MainWorldService.universeRoot(instanceRoot, "worlds")); + assertEquals(elsewhere, MainWorldService.universeRoot(instanceRoot, elsewhere.toString())); + } + + @Test + public void worldRootResolvesInsideTheUniverse() throws IOException { + Path universe = Files.createTempDirectory("iris-universe"); + Path world = Files.createDirectory(universe.resolve("survival")); + + assertEquals(world.toAbsolutePath().normalize(), + MainWorldService.resolveWorldRoot(universe, "survival")); + } + + @Test + public void worldRootRefusesToEscapeTheUniverse() throws IOException { + Path universe = Files.createTempDirectory("iris-universe"); + Files.createDirectory(universe.resolve("survival")); + + IOException escaped = assertThrows(IOException.class, + () -> MainWorldService.resolveWorldRoot(universe, "../survival")); + IOException empty = assertThrows(IOException.class, + () -> MainWorldService.resolveWorldRoot(universe, ".")); + + assertTrue(escaped.getMessage().contains("Unsafe world name")); + assertTrue(empty.getMessage().contains("Unsafe world name")); + } + + @Test + public void worldRootReportsMissingDirectoriesAsTheRecoverableCase() throws IOException { + Path universe = Files.createTempDirectory("iris-universe"); + + MainWorldService.MissingWorldRootException missingWorld = assertThrows( + MainWorldService.MissingWorldRootException.class, + () -> MainWorldService.resolveWorldRoot(universe, "survival")); + MainWorldService.MissingWorldRootException missingUniverse = assertThrows( + MainWorldService.MissingWorldRootException.class, + () -> MainWorldService.resolveWorldRoot(universe.resolve("absent"), "survival")); + + assertTrue(missingWorld.getMessage().contains("world directory does not exist")); + assertEquals(universe.resolve("survival").toAbsolutePath().normalize(), missingWorld.path()); + assertTrue(missingUniverse.getMessage().contains("universe directory does not exist")); + assertEquals(universe.resolve("absent"), missingUniverse.path()); + } + + @Test + public void unsafeWorldNameIsNotTreatedAsAMissingWorldRoot() throws IOException { + Path universe = Files.createTempDirectory("iris-universe"); + + IOException escaped = assertThrows(IOException.class, + () -> MainWorldService.resolveWorldRoot(universe, "../survival")); + + assertFalse(escaped instanceof MainWorldService.MissingWorldRootException); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomeWriterCacheTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomeWriterCacheTest.java new file mode 100644 index 000000000..c4ea59f7e --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBiomeWriterCacheTest.java @@ -0,0 +1,33 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.spi.PlatformBiome; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; + +public class ModdedBiomeWriterCacheTest { + @Test + public void unavailableServerFallsBackToBiomeIdZero() { + ModdedBiomeWriter writer = new ModdedBiomeWriter(() -> null); + + assertEquals(0, writer.biomeIdFor("minecraft:plains")); + assertEquals(0, writer.biomeIdFor("minecraft:plains")); + } + + @Test + public void unavailableServerYieldsAnEmptyMutableBiomeList() { + ModdedBiomeWriter writer = new ModdedBiomeWriter(() -> null); + + List first = writer.allBiomes(); + List second = writer.allBiomes(); + + assertTrue(first.isEmpty()); + assertNotSame("callers must never share the writer cache instance", first, second); + first.add(null); + assertTrue(second.isEmpty()); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java new file mode 100644 index 000000000..d5138586c --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockResolutionContractTest.java @@ -0,0 +1,52 @@ +package art.arcane.iris.modded; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.block.Blocks; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +/** + * The SPI splits block resolution into a null-returning lookup and an air-falling-back lookup. Modded used to + * collapse both onto air, so an unknown key produced no output at all. + */ +public class ModdedBlockResolutionContractTest { + private static final String UNKNOWN = "minecraft:definitely_not_a_real_block"; + + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void getOrNullReturnsNullForUnknownKey() { + assertNull(ModdedBlockResolution.getOrNull(UNKNOWN)); + assertNull(ModdedBlockResolution.getOrNull(UNKNOWN, true)); + } + + @Test + public void getFallsBackToAirForUnknownKey() { + ModdedBlockState state = ModdedBlockResolution.get(UNKNOWN); + assertNotNull(state); + assertEquals(Blocks.AIR, state.handle().getBlock()); + } + + @Test + public void getOrNullResolvesKnownKeyWithProperties() { + ModdedBlockState state = ModdedBlockResolution.getOrNull("minecraft:oak_log[axis=x]", true); + assertNotNull(state); + assertEquals(Blocks.OAK_LOG, state.handle().getBlock()); + } + + @Test + public void unknownPropertyFallsBackToDefaultState() { + ModdedBlockState state = ModdedBlockResolution.getOrNull("minecraft:oak_log[not_a_property=x]", true); + assertNotNull(state); + assertEquals(Blocks.OAK_LOG, state.handle().getBlock()); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedClientPackageIsolationTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedClientPackageIsolationTest.java new file mode 100644 index 000000000..8b4801420 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedClientPackageIsolationTest.java @@ -0,0 +1,103 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Dist gate. The loader source sets fold modded-common, minecraft-common and client-common into one output, so + * nothing at compile time stops a server-side class from touching a client-only type. On a dedicated server + * that is a NoClassDefFoundError at the first call, usually deep inside worldgen. + * + *

The check is a constant-pool byte scan for the internal (slash) form of the forbidden packages. Slash form + * is the point: a real type reference - supertype, field or local descriptor, method owner, class literal - is + * always stored slash-separated, while a reflective lookup keeps the class name as a dotted string constant. + * {@link ModdedMixinAudit} depends on that distinction: it names client mixin targets as dotted strings on + * purpose so it can audit them from a dedicated server, and this gate must not flag it. + * + *

Same style as the core purity gate: read the bytes, do not load the class. Loading is what the gate is + * trying to prove is safe. + */ +public class ModdedClientPackageIsolationTest { + private static final String CLIENT_MINECRAFT = "net/minecraft/client/"; + private static final String CLIENT_IRIS = "art/arcane/iris/client/"; + private static final List GUARDED_PACKAGES = List.of( + "art/arcane/iris/modded", + "art/arcane/iris/nativegen"); + private static final int MINIMUM_SCANNED = 50; + + @Test + public void serverSidePackagesNeverReferenceClientOnlyTypes() throws IOException, URISyntaxException { + Path root = classesRoot(); + List violations = new ArrayList<>(); + int scanned = 0; + for (String guarded : GUARDED_PACKAGES) { + Path directory = root.resolve(guarded); + if (!Files.isDirectory(directory)) { + continue; + } + try (Stream walk = Files.walk(directory)) { + for (Path classFile : walk.filter(ModdedClientPackageIsolationTest::isClassFile).toList()) { + scanned++; + String bytecode = readAsLatin1(classFile); + String name = root.relativize(classFile).toString(); + if (bytecode.contains(CLIENT_MINECRAFT)) { + violations.add(name + " -> " + CLIENT_MINECRAFT); + } + if (bytecode.contains(CLIENT_IRIS)) { + violations.add(name + " -> " + CLIENT_IRIS); + } + } + } + } + assertTrue("guarded packages produced only " + scanned + " classes; the scan root is wrong", + scanned >= MINIMUM_SCANNED); + assertEquals("server-side classes referencing client-only types: " + violations, + List.of(), violations); + } + + /** + * Positive control. If the needle ever stops matching - a package rename, a scan that reads the wrong + * bytes - the gate above would pass silently forever. A known client-tainted class must still trip it. + */ + @Test + public void scanDetectsClientReferencesInAKnownClientClass() throws IOException, URISyntaxException { + Path visionScreen = classesRoot().resolve("art/arcane/iris/client/IrisVisionScreen.class"); + assertTrue("IrisVisionScreen.class missing from " + visionScreen, Files.isRegularFile(visionScreen)); + assertTrue("scan needle no longer matches a known client class", + readAsLatin1(visionScreen).contains(CLIENT_MINECRAFT)); + } + + private static boolean isClassFile(Path path) { + return Files.isRegularFile(path) && path.getFileName().toString().endsWith(".class"); + } + + private static String readAsLatin1(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.ISO_8859_1); + } + + private static Path classesRoot() throws URISyntaxException { + String anchor = "/art/arcane/iris/modded/ModdedMixinFlags.class"; + URL located = ModdedClientPackageIsolationTest.class.getResource(anchor); + assertNotNull("compiled main classes are not on the test classpath as files", located); + assertEquals("expected a directory classpath entry, got " + located, "file", located.getProtocol()); + Path root = Path.of(located.toURI()); + for (int depth = 0; depth < 5; depth++) { + root = root.getParent(); + assertNotNull("walked past the classpath root resolving " + anchor, root); + } + return root; + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionRegistryStoreTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionRegistryStoreTest.java index 43162f626..9e52f4bf7 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionRegistryStoreTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionRegistryStoreTest.java @@ -6,10 +6,14 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Comparator; import java.util.List; +import java.util.stream.Stream; 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 ModdedDimensionRegistryStoreTest { @Test @@ -84,4 +88,53 @@ public class ModdedDimensionRegistryStoreTest { Files.deleteIfExists(root); } } + + @Test + public void startupLoadQuarantinesACorruptRegistryInsteadOfFailingBoot() throws IOException { + Path root = Files.createTempDirectory("iris-dimension-registry-corrupt-boot"); + Path file = root.resolve("iris-dimensions.json"); + try { + Files.writeString(file, "{\"dimensions\":[{\"id\":\"iris:lost\",", StandardCharsets.UTF_8); + + assertEquals(List.of(), ModdedDimensionRegistryStore.loadForStartup(file)); + assertFalse(Files.exists(file)); + + try (Stream entries = Files.list(root)) { + assertTrue(entries.anyMatch((Path entry) -> + entry.getFileName().toString().startsWith("iris-dimensions.json.broken-"))); + } + } finally { + deleteTree(root); + } + } + + @Test + public void startupLoadReturnsHealthyEntriesUntouched() throws IOException { + Path root = Files.createTempDirectory("iris-dimension-registry-healthy-boot"); + Path file = root.resolve("iris-dimensions.json"); + try { + List expected = List.of( + new ModdedDimensionRegistryStore.PersistentDimension( + "iris:first", "overworld", "overworld", 42L)); + ModdedDimensionRegistryStore.write(file, expected); + + assertEquals(expected, ModdedDimensionRegistryStore.loadForStartup(file)); + assertTrue(Files.exists(file)); + } finally { + deleteTree(root); + } + } + + private static void deleteTree(Path root) throws IOException { + if (!Files.exists(root)) { + return; + } + List entries; + try (Stream walk = Files.walk(root)) { + entries = walk.sorted(Comparator.reverseOrder()).toList(); + } + for (Path entry : entries) { + Files.deleteIfExists(entry); + } + } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java index 04a805d55..6778d04ff 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLifecycleFailureContractTest.java @@ -151,7 +151,8 @@ public class ModdedLifecycleFailureContractTest { assertBefore(stop, "\"services\"", "\"world engines\""); assertBefore(stop, "\"world engines\"", "\"dimension manager\""); assertBefore(stop, "\"server state\"", "if (failure != null)"); - assertTrue(stop.contains("throw propagateStopFailure(failure);")); + assertFalse(stop.contains("throw")); + assertTrue(stop.contains("LOGGER.error(\"Iris modded shutdown completed with failures\", failure);")); String runStage = method(source, "private static Throwable runStopStage("); assertTrue(runStage.contains("catch (Throwable stageFailure)")); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedServiceManagerTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedServiceManagerTest.java index bebdcd41a..e4fa49efb 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedServiceManagerTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedServiceManagerTest.java @@ -49,7 +49,7 @@ public class ModdedServiceManagerTest { } @Test - public void disableAttemptsEveryServiceInReverseOrderAndAggregatesFailures() { + public void disableAttemptsEveryServiceInReverseOrderAndNeverRethrows() { ModdedServiceManager manager = new ModdedServiceManager(); RuntimeException firstFailure = new RuntimeException("first disable failed"); RuntimeException secondFailure = new RuntimeException("second disable failed"); @@ -59,13 +59,16 @@ public class ModdedServiceManagerTest { SecondService.class, new SecondService(null, secondFailure)); manager.enableAll(); - IllegalStateException thrown = assertThrows(IllegalStateException.class, manager::disableAll); + manager.disableAll(); assertEquals(1, first.disableCount); assertEquals(1, second.disableCount); - assertSame(secondFailure, thrown.getCause()); assertEquals(1, secondFailure.getSuppressed().length); assertSame(firstFailure, secondFailure.getSuppressed()[0]); + + manager.disableAll(); + assertEquals(1, first.disableCount); + assertEquals(1, second.disableCount); } private static final class FirstService implements ModdedService { diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java index 56316ad51..a1f66b74c 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java @@ -40,6 +40,23 @@ public class ModdedStructureHooksTest { assertTrue(ModdedStructureHooks.isWithinSpan(box, -1)); } + @Test + public void chunkGridSizeCountsEveryChunkPlacementWouldLoad() { + assertEquals(1, ModdedStructureHooks.chunkGridSize(new BoundingBox(0, 0, 0, 15, 15, 15))); + assertEquals(4, ModdedStructureHooks.chunkGridSize(new BoundingBox(0, 0, 0, 16, 15, 16))); + assertEquals(9, ModdedStructureHooks.chunkGridSize(new BoundingBox(-16, 0, -16, 16, 15, 16))); + } + + @Test + public void chunkGridSizeSaturatesInsteadOfOverflowing() { + BoundingBox box = new BoundingBox( + Integer.MIN_VALUE / 2, 0, Integer.MIN_VALUE / 2, + Integer.MAX_VALUE / 2, 15, Integer.MAX_VALUE / 2); + + assertEquals(Integer.MAX_VALUE, ModdedStructureHooks.chunkGridSize(box)); + assertTrue(ModdedStructureHooks.chunkGridSize(box) > ModdedStructureHooks.MAX_PLACEMENT_CHUNKS); + } + @Test public void structurePlacementReturnsPaperOrderedBounds() { BoundingBox box = new BoundingBox(-16, -64, 32, 15, 63, 47); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileDataIdentityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileDataIdentityTest.java new file mode 100644 index 000000000..030d4547d --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileDataIdentityTest.java @@ -0,0 +1,139 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.engine.object.TileData; +import art.arcane.volmlib.util.collection.KMap; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.minecraft.SharedConstants; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.IntArrayTag; +import net.minecraft.nbt.Tag; +import net.minecraft.server.Bootstrap; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Mantle tile sections are palette backed and resolve palette ids through equals/hashCode, so two different tiles + * in one section must not share an identity - otherwise the first tile's NBT is written into every other tile. + * The superclass generates equals/hashCode from fields a modded record never populates, which is why this class + * answers identity itself. + */ +public class ModdedTileDataIdentityTest { + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + /** + * The generic map form the Bukkit side reads cannot express an NBT array: ByteArray, IntArray and LongArray all + * collapse to a plain List, and pasting that back produces a ListTag where Minecraft demands an array. A player + * head's {@code profile.id} is an IntArray of four, so a captured head pasted from the map form loses its skin. + * Capture therefore keeps the original SNBT alongside the map, and the modded paste path reads it first. + */ + @Test + public void captureKeepsSnbtSoArrayTypingSurvivesThePasteRoundTrip() throws Exception { + String snbt = "{profile:{id:[I;1,2,3,4],name:\"iris\"}}"; + + ModdedTileData captured = ModdedTileData.capture("minecraft:player_head", snbt); + + assertEquals(snbt, captured.snbt()); + assertEquals(snbt, captured.getProperties().get(ModdedTileData.NBT_PROPERTY)); + + // The Bukkit-readable map form is still written, and is still array-lossy - which is why the SNBT has to stay. + assertTrue(captured.getProperties().get("profile") instanceof KMap); + KMap profile = (KMap) captured.getProperties().get("profile"); + assertTrue(profile.get("id") instanceof List); + + CompoundTag payload = captured.payload(); + assertNotNull(payload); + Tag profileTag = payload.get("profile"); + assertTrue(profileTag instanceof CompoundTag); + Tag idTag = ((CompoundTag) profileTag).get("id"); + assertTrue("profile.id must paste back as an IntArrayTag, got " + + (idTag == null ? "null" : idTag.getClass().getSimpleName()), + idTag instanceof IntArrayTag); + assertArrayEquals(new int[]{1, 2, 3, 4}, ((IntArrayTag) idTag).getAsIntArray()); + } + + @Test + public void differentTilesAreNotEqualAndDoNotShareHashCode() { + ModdedTileData chest = tile("minecraft:chest", "Items", "diamond"); + ModdedTileData otherChest = tile("minecraft:chest", "Items", "emerald"); + ModdedTileData sign = tile("minecraft:oak_sign", "front_text", "hello"); + + assertNotEquals(chest, otherChest); + assertNotEquals(chest, sign); + // The inherited identity was a constant hash for every modded record; unequal objects are allowed to + // collide, so assert only that the hash actually varies with the record. + assertTrue(Set.of(chest.hashCode(), otherChest.hashCode(), sign.hashCode()).size() > 1); + } + + @Test + public void identicalTilesShareOnePaletteIdentity() { + ModdedTileData first = tile("minecraft:chest", "Items", "diamond"); + ModdedTileData second = tile("minecraft:chest", "Items", "diamond"); + + assertEquals(first, second); + assertEquals(first.hashCode(), second.hashCode()); + } + + @Test + public void materialKeyReportsTheBlockKeyInsteadOfNull() { + assertEquals("minecraft:chest", tile("minecraft:chest", "Items", "diamond").getMaterialKey()); + } + + @Test + public void legacyRecordHasNoBlockKey() { + ModdedTileData legacy = new ModdedTileData(new byte[]{0, 1}, new KMap<>(), null, 0); + assertNull(legacy.getMaterialKey()); + } + + @Test + public void cloneIsEqualButIndependentOfTheSource() { + ModdedTileData source = tile("minecraft:chest", "Items", "diamond"); + KMap nested = new KMap<>(); + nested.put("id", "minecraft:stone"); + source.getProperties().put("nested", nested); + source.getProperties().put("list", List.of("a", "b")); + + TileData copy = source.clone(); + + assertNotSame(source, copy); + assertNotSame(source.getProperties(), copy.getProperties()); + assertEquals(source.getProperties(), copy.getProperties()); + assertNotSame(source.getProperties().get("nested"), copy.getProperties().get("nested")); + assertTrue(copy.getProperties().get("list") instanceof List); + } + + private static ModdedTileData tile(String blockKey, String propertyKey, String propertyValue) { + KMap properties = new KMap<>(); + properties.put(propertyKey, propertyValue); + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(bytes)) { + out.writeUTF(blockKey); + out.writeUTF(GSON.toJson(properties)); + } + return new ModdedTileData(bytes.toByteArray(), properties, blockKey, -1); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java index 35899b5a8..ddf92a2c2 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java @@ -19,11 +19,11 @@ import static org.junit.Assert.assertTrue; public class ModdedWorldCheckTest { @Test - public void coordinatorThreadIsNonDaemon() { + public void coordinatorThreadIsDaemonSoItCannotWedgeTheJvm() { Thread thread = ModdedWorldCheck.coordinatorThread(() -> { }); - assertFalse(thread.isDaemon()); + assertTrue(thread.isDaemon()); } @Test @@ -33,7 +33,7 @@ public class ModdedWorldCheckTest { String auditSource = Files.readString( sourceRoot.resolve("art/arcane/iris/modded/WorldCheckStructureAudit.java")); int preparationSubmit = source.indexOf( - "WorldCheckPreparation preparation = serverRef.submit(() -> run(serverRef)).join();"); + "WorldCheckPreparation preparation = serverRef.submit(() -> run(serverRef))"); int completionSubmit = source.indexOf( "exitCode = serverRef.submit(() -> runAndRequestStop(", preparationSubmit); int completionMethod = source.indexOf("private static boolean completeWorldCheck"); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldManagerParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldManagerParityTest.java index 537a52728..1882c95ae 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldManagerParityTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldManagerParityTest.java @@ -2,10 +2,25 @@ package art.arcane.iris.modded; import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class ModdedWorldManagerParityTest { + @Test + public void ambientChunkReservoirFillsBeforeSampling() { + assertEquals(0, ModdedWorldManager.reservoirSlot(0, 4, 0)); + assertEquals(3, ModdedWorldManager.reservoirSlot(3, 4, 0)); + } + + @Test + public void ambientChunkReservoirReplacesOnlyOnInRangeRolls() { + assertEquals(2, ModdedWorldManager.reservoirSlot(4, 4, 2)); + assertEquals(0, ModdedWorldManager.reservoirSlot(9, 4, 0)); + assertEquals(-1, ModdedWorldManager.reservoirSlot(4, 4, 4)); + assertEquals(-1, ModdedWorldManager.reservoirSlot(9, 4, 7)); + } + @Test public void normalWorldAlwaysAllowsEntitySpawning() { assertTrue(ModdedWorldManager.entitySpawningEnabled(false, false)); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java index e67ec2661..94c986e2d 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java @@ -7,6 +7,8 @@ import net.minecraft.commands.CommandSourceStack; import net.minecraft.server.Bootstrap; import org.junit.Test; +import java.util.List; + import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -75,6 +77,33 @@ public class IrisModdedCommandParityTest { assertTrue(ModdedCommandHelp.documents("world", "mainworld")); } + @Test + public void categoryLiteralsFallBackToHelpWithoutArguments() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + CommandDispatcher dispatcher = new CommandDispatcher<>(); + + IrisModdedCommands.register(dispatcher); + + CommandNode iris = child(dispatcher.getRoot(), "iris"); + assertNotNull("iris", iris.getCommand()); + for (String category : new String[]{"help", "find", "goto", "pregen", "pregenerate", "object", "o", + "edit", "studio", "std", "s", "pack", "pk", "world", "w", "datapack", "datapacks", "dp", + "structure", "struct", "str", "developer", "dev"}) { + assertNotNull(category, child(iris, category).getCommand()); + } + } + + @Test + public void consoleHelpListsEveryEntryWithInlineUsageAndDescription() { + List root = ModdedCommandHelp.consoleLines(""); + + assertTrue(root.stream().anyMatch((String line) -> line.startsWith("/iris developer (dev) - "))); + assertTrue(root.stream().anyMatch((String line) -> line.startsWith("/iris teleport [player] (tp) - "))); + assertTrue(ModdedCommandHelp.consoleLines("pregen").stream().anyMatch((String line) -> + line.startsWith("/iris pregen start [dimension] [at] [x] [z] [gui] [sync] [nocache] - "))); + } + private static CommandNode child( CommandNode parent, String name) { CommandNode child = parent.getChild(name); diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index 188ea6c0b..eab87581e 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -207,6 +207,7 @@ tasks.named('shadowJar', ShadowJar).configure { doFirst { delete(fileTree(layout.buildDirectory.dir('libs')) { include('Iris v* [NeoForge] *.jar') + include('iris-neoforge-*.jar') }) delete(layout.buildDirectory.file("libs/Iris-${project.version}+mc${minecraftVersion}-neoforge.jar")) } @@ -219,15 +220,43 @@ tasks.named('shadowJar', ShadowJar).configure { exclude('META-INF/*.SF') exclude('META-INF/*.DSA') exclude('META-INF/*.RSA') - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm') - relocate('io.sentry', 'art.arcane.iris.shadow.sentry') + // The only multi-release entry any bundled dependency contributes is an OSGi manifest. Keeping + // it forces `Multi-Release: true` onto the mod jar, which makes the loaders treat the whole + // archive as versioned and re-scan it per release directory for nothing. + exclude('META-INF/versions/**') + addMultiReleaseAttribute.set(false) + // Invalid package name on Java 9+ ('enum' is a keyword); a module-path scan chokes on it. + exclude('org/apache/commons/lang/enum/**') + // Compile-only annotation carriers. Shipping them duplicates packages other mods also ship. exclude('org/jspecify/**') exclude('com/google/errorprone/**') exclude('com/google/j2objc/**') exclude('org/checkerframework/**') exclude('org/jetbrains/annotations/**') exclude('org/intellij/lang/**') + // The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod + // loader has. Shipping it only adds dead classes that reference a missing package. + exclude('art/arcane/iris/platform/bukkit/**') + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm') + relocate('io.sentry', 'art.arcane.iris.shadow.sentry') + // Split-package guard. A co-installed mod that ships any of these at their original coordinates + // puts two copies of the same package on one module layer, which the loaders reject at boot + // (Terra ships paralithic; caffeine and dom4j are common in mod dependency trees). First-party + // art.arcane.volmlib is deliberately NOT relocated. + relocate('com.github.benmanes.caffeine', 'art.arcane.iris.shadow.caffeine') + relocate('com.googlecode.concurrentlinkedhashmap', 'art.arcane.iris.shadow.clhm') + relocate('com.dfsek.paralithic', 'art.arcane.iris.shadow.paralithic') + relocate('org.dom4j', 'art.arcane.iris.shadow.dom4j') + relocate('org.jaxen', 'art.arcane.iris.shadow.jaxen') + relocate('org.zeroturnaround.zip', 'art.arcane.iris.shadow.ztzip') +} + +// The thin `jar` output carries valid neoforge.mods.toml metadata but bundles zero dependencies. +// Installing it is an instant NoClassDefFoundError, and it sits in build/libs right next to the real +// artifact. shadowJar is the only shippable NeoForge jar. +tasks.named('jar').configure { + enabled = false } tasks.named('assemble').configure { diff --git a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java index df3557f7f..687b40a91 100644 --- a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java +++ b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java @@ -97,13 +97,30 @@ public final class IrisNeoForgeBootstrap { NeoForge.EVENT_BUS.addListener((RegisterCommandsEvent event) -> IrisModdedCommands.register(event.getDispatcher())); NeoForge.EVENT_BUS.addListener((PermissionGatherEvent.Nodes event) -> event.addNodes(NeoForgeModdedLoader.TREE_FELLER_PERMISSION)); - NeoForge.EVENT_BUS.addListener((BreakBlockEvent event) -> { - if (event.getLevel() instanceof ServerLevel level - && event.getPlayer() instanceof ServerPlayer player - && !event.isCanceled()) { - ModdedBlockBreakHandler.prepare(level, player, event.getPos(), event.getState()); - } - }); + // LOWEST so every other mod has already had its say about cancelling the break before Iris records + // provenance for it. + NeoForge.EVENT_BUS.addListener( + EventPriority.LOWEST, + false, + (BreakBlockEvent event) -> { + if (event.getLevel() instanceof ServerLevel level + && event.getPlayer() instanceof ServerPlayer player) { + ModdedBlockBreakHandler.prepare(level, player, event.getPos(), event.getState()); + } + } + ); + // Parity with the Fabric PlayerBlockBreakEvents.CANCELED hook: drop the prepared entry when the break + // never happens. It only observes cancellations from listeners registered before it at LOWEST; the + // 1-tick sweeper ModdedBlockBreakHandler.prepare schedules covers every other case. + NeoForge.EVENT_BUS.addListener( + EventPriority.LOWEST, + true, + (BreakBlockEvent event) -> { + if (event.isCanceled() && event.getLevel() instanceof ServerLevel level) { + ModdedBlockBreakHandler.cancel(level, event.getPos()); + } + } + ); NeoForge.EVENT_BUS.addListener( EventPriority.LOWEST, false, diff --git a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeClient.java b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeClient.java index 720267419..e0b5c5694 100644 --- a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeClient.java +++ b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeClient.java @@ -54,6 +54,7 @@ public final class IrisNeoForgeClient { NeoForge.EVENT_BUS.addListener((ClientPlayerNetworkEvent.LoggingOut event) -> IrisClient.onDisconnect()); NeoForge.EVENT_BUS.addListener((ClientTickEvent.Post event) -> { IrisClient.tick(); + IrisClientHud.tick(); IrisClientKeybinds.pollToggle(); }); } diff --git a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java index ef0d4e2ca..7bf882e72 100644 --- a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java +++ b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/NeoForgeModdedLoader.java @@ -43,7 +43,7 @@ import java.nio.file.Path; public final class NeoForgeModdedLoader implements ModdedLoader { public static final PermissionNode TREE_FELLER_PERMISSION = new PermissionNode<>( - "iris", + "irisworldgen", "treefeller", PermissionTypes.BOOLEAN, (player, playerId, contexts) -> diff --git a/adapters/neoforge/src/main/resources/META-INF/neoforge.mods.toml b/adapters/neoforge/src/main/resources/META-INF/neoforge.mods.toml index 5404c7245..cd81fa963 100644 --- a/adapters/neoforge/src/main/resources/META-INF/neoforge.mods.toml +++ b/adapters/neoforge/src/main/resources/META-INF/neoforge.mods.toml @@ -1,6 +1,7 @@ modLoader = "javafml" loaderVersion = "[3,)" license = "GPL-3.0" +issueTrackerURL = "https://github.com/VolmitSoftware/Iris/issues" [[mixins]] config = "irisworldgen.entity.mixins.json" @@ -8,17 +9,23 @@ config = "irisworldgen.entity.mixins.json" [[mixins]] config = "irisworldgen.client.mixins.json" +[[accessTransformers]] +file = "META-INF/accesstransformer.cfg" + [[mods]] modId = "irisworldgen" version = "${version}" displayName = "Iris" description = "Iris World Generation Engine (NeoForge adapter - native chunk generator, explicit Iris world datapack workflow, engine lifecycle)" authors = "Arcane Arts (Volmit Software)" +credits = "cyberpwn, NextdoorPsycho, Vatuu" +displayURL = "https://docs.volmit.com/iris/" +logoFile = "assets/irisworldgen/icon.png" [[dependencies.irisworldgen]] modId = "neoforge" type = "required" -versionRange = "[26.2,)" +versionRange = "[26.2,26.3)" ordering = "NONE" side = "BOTH" diff --git a/build.gradle b/build.gradle index d232377dc..017fa3d76 100644 --- a/build.gradle +++ b/build.gradle @@ -172,6 +172,73 @@ tasks.register('buildBukkit', Copy) { } } +// The three loader jars come out of nested standalone Gradle builds driven through Exec, so Gradle +// cannot see through to their real inputs and re-runs all three on every buildAll. The declarations +// below give each Exec a truthful input set. Anything that feeds a loader jar has to be listed here, +// including the VolmLib checkout when source substitution is active - an unlisted source tree means +// an edit there is silently ignored and a stale jar is treated as up to date. +Closure resolveLocalVolmLibDirectory = { -> + String configured = localVolmLibDirectory != null && !localVolmLibDirectory.isBlank() + ? localVolmLibDirectory + : System.getenv('VOLMLIB_DIR') + if (configured != null && !configured.isBlank()) { + File configuredDirectory = new File(configured) + if (!configuredDirectory.isAbsolute()) { + configuredDirectory = new File(rootDir, configured) + } + return configuredDirectory.isDirectory() ? configuredDirectory : null + } + + File current = rootDir + while (current != null) { + File candidate = new File(current, 'VolmLib') + if (new File(candidate, 'settings.gradle').isFile() || new File(candidate, 'settings.gradle.kts').isFile()) { + return candidate + } + + current = current.parentFile + } + + return null +} + +List moddedSharedInputs = [] +[ + 'adapters/modded-common/src', + 'adapters/minecraft-common/src', + 'adapters/client-common/src', + 'core/src/main', + 'core/build.gradle', + 'spi/src/main', + 'spi/build.gradle', + 'build.gradle', + 'settings.gradle', + 'gradle.properties', + 'gradle/libs.versions.toml', + 'gradle/volmlib-resolution.settings.gradle' +].each { String path -> moddedSharedInputs.add(layout.projectDirectory.file(path).asFile) } +File localVolmLibCheckout = useLocalVolmLib.equalsIgnoreCase('true') ? resolveLocalVolmLibDirectory() : null +if (localVolmLibCheckout != null) { + moddedSharedInputs.add(new File(localVolmLibCheckout, 'shared/src/main')) +} + +Closure declareModdedJarIo = { Exec task, String adapter, String artifactName, Map versions -> + task.inputs.files(moddedSharedInputs).withPropertyName('moddedSharedSources') + task.inputs.files( + layout.projectDirectory.file("adapters/${adapter}/src").asFile, + layout.projectDirectory.file("adapters/${adapter}/build.gradle").asFile, + layout.projectDirectory.file("adapters/${adapter}/settings.gradle").asFile + ).withPropertyName('adapterSources') + versions.each { String key, String value -> task.inputs.property(key, value) } + task.inputs.property('useLocalVolmLib', useLocalVolmLib) + task.inputs.property('volmLibCoordinate', volmLibCoordinate) + task.inputs.property('localVolmLibCheckout', localVolmLibCheckout == null ? '' : localVolmLibCheckout.absolutePath) + task.outputs.file(layout.projectDirectory.file("adapters/${adapter}/build/libs/${artifactName}")) + // The nested build owns its own caching; a remote cache entry here would be a lie. + task.outputs.cacheIf { false } + return null +} + tasks.register('fabricJar', Exec) { group = 'iris' workingDir = layout.projectDirectory.dir('adapters/fabric').asFile @@ -190,6 +257,11 @@ tasks.register('fabricJar', Exec) { command.add("-PlocalVolmLibDirectory=${localVolmLibDirectory}") } commandLine(command) + declareModdedJarIo(it, 'fabric', fabricArtifactName, [ + irisVersion: project.version.toString(), + minecraftVersion: minecraftVersion, + fabricLoaderVersion: fabricLoaderVersion + ]) } tasks.register('buildFabric', Copy) { @@ -223,6 +295,11 @@ tasks.register('forgeJar', Exec) { command.add("-PlocalVolmLibDirectory=${localVolmLibDirectory}") } commandLine(command) + declareModdedJarIo(it, 'forge', forgeArtifactName, [ + irisVersion: project.version.toString(), + minecraftVersion: minecraftVersion, + forgeVersion: forgeVersion + ]) } tasks.register('buildForge', Copy) { @@ -256,6 +333,11 @@ tasks.register('neoforgeJar', Exec) { command.add("-PlocalVolmLibDirectory=${localVolmLibDirectory}") } commandLine(command) + declareModdedJarIo(it, 'neoforge', neoForgeArtifactName, [ + irisVersion: project.version.toString(), + minecraftVersion: minecraftVersion, + neoForgeVersion: neoForgeVersion + ]) } tasks.register('buildNeoforge', Copy) { @@ -289,9 +371,47 @@ List commonModdedArtifactEntries = [ 'art/arcane/iris/engine/IrisEngine.class', 'art/arcane/iris/spi/IrisPlatform.class', 'art/arcane/volmlib/util/mantle/io/Lz4IOWorkerCodecSupport.class', - 'art/arcane/iris/util/common/misc/getHardware.class' + 'art/arcane/iris/util/common/misc/getHardware.class', + // Both shared mixin configs plus the lang file and mod icon every loader's metadata points at. + // A shadowJar exclude or a renamed resource that drops any of these is silent at build time and + // shows up as a non-applied mixin or a missing-translation report from a user. + 'irisworldgen.entity.mixins.json', + 'irisworldgen.client.mixins.json', + 'assets/irisworldgen/lang/en_us.json', + 'assets/irisworldgen/icon.png' ] +// Frozen inventory of shipped classes that extend or implement an org.bukkit type. They come from +// core and VolmLib, sit on Bukkit-only code paths the mod loaders never resolve, and are inert dead +// weight rather than a boot hazard - excluding them means chasing the whole core reference graph. +// The verifier fails on anything NEW outside this set, which is the part that would be a regression. +// Shrinking the set (WP4 deletions, retypes) needs no edit here; growing it does. +Set knownBukkitSupertypeClasses = [ + 'art/arcane/iris/core/events/IrisEngineEvent.class', + 'art/arcane/iris/core/events/IrisLootEvent.class', + 'art/arcane/iris/core/events/IrisLootEvent$1.class', + 'art/arcane/iris/core/events/IrisLootEvent$2.class', + 'art/arcane/iris/core/link/data/ItemAdderDataProvider.class', + 'art/arcane/iris/core/link/data/MythicMobsDataProvider.class', + 'art/arcane/iris/engine/framework/EngineAssignedWorldManager.class', + 'art/arcane/iris/engine/object/IrisEntity$BukkitOps$1.class', + 'art/arcane/iris/engine/object/LegacyTileData$SignTags$1.class', + 'art/arcane/iris/engine/platform/BukkitChunkGenerator.class', + 'art/arcane/iris/engine/platform/DummyBiomeProvider.class', + 'art/arcane/iris/engine/platform/DummyChunkGenerator.class', + 'art/arcane/iris/util/common/data/IrisCustomData.class', + 'art/arcane/iris/util/common/plugin/IrisService.class', + 'art/arcane/iris/util/common/plugin/VolmitPlugin.class', + 'art/arcane/iris/util/common/plugin/VolmitSender.class', + 'art/arcane/iris/util/common/plugin/chunk/ChunkTickets.class', + 'art/arcane/volmlib/util/board/BoardUpdateTask.class', + 'art/arcane/volmlib/util/bukkit/Events.class', + 'art/arcane/volmlib/util/data/Cuboid.class', + 'art/arcane/volmlib/util/data/InvertedBiomeGrid.class', + 'art/arcane/volmlib/util/data/IrisBiomeStorage.class', + 'art/arcane/volmlib/util/inventorygui/UIWindow.class' +] as Set + tasks.register('verifyFabricArtifact') { group = 'verification' dependsOn('fabricJar') @@ -300,10 +420,12 @@ tasks.register('verifyFabricArtifact') { doLast { List requiredEntries = commonModdedArtifactEntries + [ 'fabric.mod.json', + 'irisworldgen.mixins.json', + 'irisworldgen.accesswidener', 'art/arcane/iris/fabric/IrisFabricBootstrap.class' ] - ModdedArtifactVerifier.verify(artifact, requiredEntries) - logger.lifecycle("Verified ${artifact.name} uses Minecraft-provided LZ4, OSHI, and JNA") + ModdedArtifactVerifier.verify(artifact, requiredEntries, knownBukkitSupertypeClasses) + logger.lifecycle("Verified ${artifact.name} packaging, mixin compat, and nested jars") } } @@ -315,10 +437,11 @@ tasks.register('verifyForgeArtifact') { doLast { List requiredEntries = commonModdedArtifactEntries + [ 'META-INF/mods.toml', + 'META-INF/accesstransformer.cfg', 'art/arcane/iris/forge/IrisForgeBootstrap.class' ] - ModdedArtifactVerifier.verify(artifact, requiredEntries) - logger.lifecycle("Verified ${artifact.name} uses Minecraft-provided LZ4, OSHI, and JNA") + ModdedArtifactVerifier.verify(artifact, requiredEntries, knownBukkitSupertypeClasses) + logger.lifecycle("Verified ${artifact.name} packaging, mixin compat, and nested jars") } } @@ -330,10 +453,11 @@ tasks.register('verifyNeoforgeArtifact') { doLast { List requiredEntries = commonModdedArtifactEntries + [ 'META-INF/neoforge.mods.toml', + 'META-INF/accesstransformer.cfg', 'art/arcane/iris/neoforge/IrisNeoForgeBootstrap.class' ] - ModdedArtifactVerifier.verify(artifact, requiredEntries) - logger.lifecycle("Verified ${artifact.name} uses Minecraft-provided LZ4, OSHI, and JNA") + ModdedArtifactVerifier.verify(artifact, requiredEntries, knownBukkitSupertypeClasses) + logger.lifecycle("Verified ${artifact.name} packaging, mixin compat, and nested jars") } } diff --git a/buildSrc/src/main/java/ModdedArtifactVerifier.java b/buildSrc/src/main/java/ModdedArtifactVerifier.java index 0fe60a750..30c4305db 100644 --- a/buildSrc/src/main/java/ModdedArtifactVerifier.java +++ b/buildSrc/src/main/java/ModdedArtifactVerifier.java @@ -1,4 +1,5 @@ import org.gradle.api.GradleException; +import org.objectweb.asm.ClassReader; import java.io.File; import java.io.IOException; @@ -12,6 +13,9 @@ import java.util.Set; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.jar.JarInputStream; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public final class ModdedArtifactVerifier { private static final String CODEC_CLASS = "art/arcane/volmlib/util/mantle/io/Lz4IOWorkerCodecSupport.class"; @@ -33,11 +37,40 @@ public final class ModdedArtifactVerifier { "art/arcane/iris/shadow/oshi", "art/arcane/iris/shadow/jna" ); + // ASM must always be relocated: the loaders put their own copy on the module layer and a second + // org.objectweb.asm package on the same layer is a boot failure. + private static final String ASM_PREFIX = "org/objectweb/asm/"; + // Only the Bukkit platform binding is allowed to sit on org.bukkit types, and the modded jars + // exclude it outright. Kept as an exemption so the same check can run over the Bukkit artifact. + private static final String BUKKIT_PLATFORM_PREFIX = "art/arcane/iris/platform/bukkit/"; + private static final String BUKKIT_TYPE_PREFIX = "org/bukkit/"; + private static final String FABRIC_METADATA = "fabric.mod.json"; + private static final String NEOFORGE_METADATA = "META-INF/neoforge.mods.toml"; + private static final String NESTED_JAR_DIRECTORY = "META-INF/jars/"; + private static final String MIXIN_CONFIGS_ATTRIBUTE = "MixinConfigs"; + // Forge 26.2 ships upstream Mixin 0.8.7, whose MixinEnvironment.CompatibilityLevel enum ends at + // JAVA_21. Anything higher is a hard MixinInitialisationError at Forge boot even though Fabric's + // fork accepts it, so the shared configs are capped here. + private static final int MAX_MIXIN_COMPATIBILITY_LEVEL = 21; + private static final Pattern TOML_CONFIG_ASSIGNMENT = Pattern.compile("(?m)^\\s*config\\s*=\\s*\"([^\"]+)\""); + private static final Pattern COMPATIBILITY_LEVEL = Pattern.compile("\"compatibilityLevel\"\\s*:\\s*\"([^\"]*)\""); + private static final Pattern COMPATIBILITY_LEVEL_VALUE = Pattern.compile("JAVA_(\\d+)"); + private static final Pattern QUOTED_JSON_FILE = Pattern.compile("\"([^\"]+\\.(?:json|jar))\""); private ModdedArtifactVerifier() { } public static void verify(File artifact, List requiredEntries) { + verify(artifact, requiredEntries, Set.of()); + } + + /** + * @param bukkitSupertypeBaseline entry names (as they appear in the jar) that are known to + * extend or implement an {@code org.bukkit} type. New offenders + * outside this frozen set fail the build; entries that disappear + * from the artifact are not an error. + */ + public static void verify(File artifact, List requiredEntries, Set bukkitSupertypeBaseline) { if (!artifact.isFile()) { throw new GradleException("Missing modded Iris artifact: " + artifact.getAbsolutePath()); } @@ -52,6 +85,9 @@ public final class ModdedArtifactVerifier { Set bundledRuntimeEntries = new LinkedHashSet<>(); Set privateReferenceClasses = new LinkedHashSet<>(); Set codecReferences = new LinkedHashSet<>(); + Set asmEntries = new LinkedHashSet<>(); + Set shippedNestedJars = new LinkedHashSet<>(); + Set bukkitSupertypeClasses = new LinkedHashSet<>(); boolean oshiReference = false; Enumeration entries = jar.entries(); while (entries.hasMoreElements()) { @@ -64,6 +100,12 @@ public final class ModdedArtifactVerifier { if (startsWithAny(name, BUNDLED_RUNTIME_PREFIXES)) { bundledRuntimeEntries.add(name); } + if (name.startsWith(ASM_PREFIX)) { + asmEntries.add(name); + } + if (name.startsWith(NESTED_JAR_DIRECTORY) && name.endsWith(".jar")) { + shippedNestedJars.add(name); + } if (isNestedJar(name)) { inspectNestedJar(jar, entry, bundledRuntimeEntries, privateReferenceClasses); } @@ -71,7 +113,8 @@ public final class ModdedArtifactVerifier { continue; } - String bytecode = readClass(jar, entry); + byte[] classFile = readEntryBytes(jar, entry); + String bytecode = new String(classFile, StandardCharsets.ISO_8859_1); if (containsAny(bytecode, PRIVATE_REFERENCE_PREFIXES)) { privateReferenceClasses.add(name); } @@ -85,6 +128,11 @@ public final class ModdedArtifactVerifier { } } } + if (!name.startsWith(BUKKIT_PLATFORM_PREFIX) + && !bukkitSupertypeBaseline.contains(name) + && hasBukkitSupertype(classFile)) { + bukkitSupertypeClasses.add(name); + } } if (!bundledRuntimeEntries.isEmpty()) { @@ -101,14 +149,181 @@ public final class ModdedArtifactVerifier { if (!oshiReference) { throw new GradleException(artifact.getName() + " does not link hardware diagnostics to Minecraft's OSHI runtime"); } + if (!asmEntries.isEmpty()) { + throw new GradleException(artifact.getName() + " ships unrelocated ASM: " + preview(asmEntries)); + } + if (!bukkitSupertypeClasses.isEmpty()) { + throw new GradleException(artifact.getName() + " ships classes with an org.bukkit supertype outside " + + BUKKIT_PLATFORM_PREFIX + ": " + preview(bukkitSupertypeClasses)); + } + + verifyNestedJarDeclarations(artifact, jar, shippedNestedJars); + verifyMixinCompatibilityLevels(artifact, jar); } catch (IOException e) { throw new GradleException("Unable to verify modded Iris artifact " + artifact.getAbsolutePath(), e); } } - private static String readClass(JarFile jar, JarEntry entry) throws IOException { + private static void verifyNestedJarDeclarations(File artifact, JarFile jar, Set shippedNestedJars) + throws IOException { + String metadata = readEntryText(jar, FABRIC_METADATA); + if (metadata == null) { + if (!shippedNestedJars.isEmpty()) { + throw new GradleException(artifact.getName() + " ships nested jars but has no " + FABRIC_METADATA + + " to declare them: " + preview(shippedNestedJars)); + } + return; + } + + Set declared = collectQuotedFiles(extractJsonArray(metadata, "jars"), ".jar"); + Set undeclared = new LinkedHashSet<>(shippedNestedJars); + undeclared.removeAll(declared); + if (!undeclared.isEmpty()) { + throw new GradleException(artifact.getName() + " ships nested jars that " + FABRIC_METADATA + + " does not declare: " + preview(undeclared)); + } + + Set missing = new LinkedHashSet<>(declared); + missing.removeAll(shippedNestedJars); + if (!missing.isEmpty()) { + throw new GradleException(artifact.getName() + " declares nested jars in " + FABRIC_METADATA + + " that it does not ship: " + preview(missing)); + } + } + + private static void verifyMixinCompatibilityLevels(File artifact, JarFile jar) throws IOException { + Set configs = new LinkedHashSet<>(); + Manifest manifest = jar.getManifest(); + if (manifest != null) { + String declared = manifest.getMainAttributes().getValue(MIXIN_CONFIGS_ATTRIBUTE); + if (declared != null) { + for (String config : declared.split(",")) { + String trimmed = config.trim(); + if (!trimmed.isEmpty()) { + configs.add(trimmed); + } + } + } + } + + String neoforgeMetadata = readEntryText(jar, NEOFORGE_METADATA); + if (neoforgeMetadata != null) { + Matcher matcher = TOML_CONFIG_ASSIGNMENT.matcher(neoforgeMetadata); + while (matcher.find()) { + configs.add(matcher.group(1)); + } + } + + String fabricMetadata = readEntryText(jar, FABRIC_METADATA); + if (fabricMetadata != null) { + configs.addAll(collectQuotedFiles(extractJsonArray(fabricMetadata, "mixins"), ".json")); + } + + if (configs.isEmpty()) { + throw new GradleException(artifact.getName() + " registers no mixin configs; expected the " + + MIXIN_CONFIGS_ATTRIBUTE + " manifest attribute (Forge), [[mixins]] (NeoForge), or a mixins " + + "array (Fabric)"); + } + + for (String config : configs) { + String body = readEntryText(jar, config); + if (body == null) { + throw new GradleException(artifact.getName() + " registers mixin config " + config + + " which is not in the jar"); + } + + Matcher matcher = COMPATIBILITY_LEVEL.matcher(body); + if (!matcher.find()) { + continue; + } + + String level = matcher.group(1); + Matcher value = COMPATIBILITY_LEVEL_VALUE.matcher(level); + if (!value.matches()) { + throw new GradleException(artifact.getName() + " mixin config " + config + + " has unparseable compatibilityLevel " + level); + } + if (Integer.parseInt(value.group(1)) > MAX_MIXIN_COMPATIBILITY_LEVEL) { + throw new GradleException(artifact.getName() + " mixin config " + config + + " requests compatibilityLevel " + level + "; upstream Mixin 0.8.7 (Forge) tops out at JAVA_" + + MAX_MIXIN_COMPATIBILITY_LEVEL); + } + } + } + + private static boolean hasBukkitSupertype(byte[] classFile) { + ClassReader reader; + try { + reader = new ClassReader(classFile); + } catch (RuntimeException e) { + // Class file version newer than the bundled ASM. Nothing to assert about it. + return false; + } + + String superName = reader.getSuperName(); + if (superName != null && superName.startsWith(BUKKIT_TYPE_PREFIX)) { + return true; + } + for (String candidate : reader.getInterfaces()) { + if (candidate.startsWith(BUKKIT_TYPE_PREFIX)) { + return true; + } + } + return false; + } + + /** + * Returns the balanced {@code [...]} block that follows {@code "key"}, or an empty string. + */ + private static String extractJsonArray(String json, String key) { + int keyIndex = json.indexOf('"' + key + '"'); + if (keyIndex < 0) { + return ""; + } + + int open = json.indexOf('[', keyIndex); + if (open < 0) { + return ""; + } + + int depth = 0; + for (int i = open; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '[') { + depth++; + } else if (c == ']') { + depth--; + if (depth == 0) { + return json.substring(open, i + 1); + } + } + } + return ""; + } + + private static Set collectQuotedFiles(String region, String suffix) { + Set found = new LinkedHashSet<>(); + Matcher matcher = QUOTED_JSON_FILE.matcher(region); + while (matcher.find()) { + String value = matcher.group(1); + if (value.endsWith(suffix)) { + found.add(value); + } + } + return found; + } + + private static String readEntryText(JarFile jar, String name) throws IOException { + JarEntry entry = jar.getJarEntry(name); + if (entry == null) { + return null; + } + return new String(readEntryBytes(jar, entry), StandardCharsets.UTF_8); + } + + private static byte[] readEntryBytes(JarFile jar, JarEntry entry) throws IOException { try (InputStream input = jar.getInputStream(entry)) { - return new String(input.readAllBytes(), StandardCharsets.ISO_8859_1); + return input.readAllBytes(); } } @@ -163,7 +378,7 @@ public final class ModdedArtifactVerifier { private static boolean isNestedJar(String name) { return name.endsWith(".jar") - && (name.startsWith("META-INF/jars/") || name.startsWith("META-INF/jarjar/")); + && (name.startsWith(NESTED_JAR_DIRECTORY) || name.startsWith("META-INF/jarjar/")); } private static boolean isNamedRuntimeLibrary(String name) { diff --git a/buildSrc/src/test/java/ModdedArtifactVerifierTest.java b/buildSrc/src/test/java/ModdedArtifactVerifierTest.java index f55977d9a..42f4ec7ae 100644 --- a/buildSrc/src/test/java/ModdedArtifactVerifierTest.java +++ b/buildSrc/src/test/java/ModdedArtifactVerifierTest.java @@ -2,6 +2,8 @@ import org.gradle.api.GradleException; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; import java.io.ByteArrayOutputStream; import java.io.File; @@ -10,8 +12,11 @@ import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.jar.Attributes; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -20,6 +25,9 @@ public class ModdedArtifactVerifierTest { private static final String METADATA = "fabric.mod.json"; private static final String CODEC_CLASS = "art/arcane/volmlib/util/mantle/io/Lz4IOWorkerCodecSupport.class"; private static final String HARDWARE_CLASS = "art/arcane/iris/util/common/misc/getHardware.class"; + private static final String MIXIN_CONFIG = "irisworldgen.entity.mixins.json"; + private static final String CLIENT_MIXIN_CONFIG = "irisworldgen.client.mixins.json"; + private static final String NEOFORGE_METADATA = "META-INF/neoforge.mods.toml"; private static final List REQUIRED_ENTRIES = List.of(METADATA, CODEC_CLASS, HARDWARE_CLASS); private static final byte[] INTERNAL_CODEC = ( "net/jpountz/lz4/LZ4BlockInputStream net/jpountz/lz4/LZ4BlockOutputStream" @@ -96,17 +104,224 @@ public class ModdedArtifactVerifierTest { assertTrue(failure.getMessage().contains("is missing " + METADATA)); } + @Test + public void rejectsUnrelocatedAsm() throws Exception { + Map entries = validEntries(); + entries.put("org/objectweb/asm/ClassReader.class", new byte[]{0}); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES)); + assertTrue(failure.getMessage().contains("ships unrelocated ASM")); + } + + @Test + public void rejectsNestedJarMissingFromMetadata() throws Exception { + Map entries = validEntries(); + entries.put("META-INF/jars/fabric-rendering-v1.jar", createNestedArtifact(Map.of( + "net/fabricmc/Placeholder.class", new byte[]{0} + ))); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES)); + assertTrue(failure.getMessage().contains("does not declare")); + } + + @Test + public void rejectsDeclaredNestedJarThatIsNotShipped() throws Exception { + Map entries = validEntries(); + entries.put(METADATA, fabricMetadata( + "[ { \"file\": \"META-INF/jars/fabric-api-base.jar\" } ]", + "[ \"" + MIXIN_CONFIG + "\" ]")); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES)); + assertTrue(failure.getMessage().contains("does not ship")); + } + + @Test + public void acceptsNestedJarDeclaredInMetadata() throws Exception { + Map entries = validEntries(); + entries.put(METADATA, fabricMetadata( + "[ { \"file\": \"META-INF/jars/fabric-api-base.jar\" } ]", + "[ \"" + MIXIN_CONFIG + "\" ]")); + entries.put("META-INF/jars/fabric-api-base.jar", createNestedArtifact(Map.of( + "net/fabricmc/Placeholder.class", new byte[]{0} + ))); + File artifact = createArtifact(entries); + + ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES); + } + + @Test + public void rejectsMixinCompatibilityLevelAboveJava21() throws Exception { + Map entries = validEntries(); + entries.put(MIXIN_CONFIG, mixinConfig("JAVA_25")); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES)); + assertTrue(failure.getMessage().contains("compatibilityLevel JAVA_25")); + } + + @Test + public void rejectsMixinCompatibilityLevelAboveJava21RegisteredByManifest() throws Exception { + Map entries = new LinkedHashMap<>(); + entries.put("META-INF/mods.toml", "modId = \"irisworldgen\"".getBytes(StandardCharsets.UTF_8)); + entries.put(CODEC_CLASS, INTERNAL_CODEC); + entries.put(HARDWARE_CLASS, "oshi/SystemInfo".getBytes(StandardCharsets.ISO_8859_1)); + entries.put(CLIENT_MIXIN_CONFIG, mixinConfig("JAVA_25")); + File artifact = createArtifact(entries, manifestWithMixinConfigs(CLIENT_MIXIN_CONFIG)); + + GradleException failure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verify(artifact, List.of(CODEC_CLASS, HARDWARE_CLASS))); + assertTrue(failure.getMessage().contains("compatibilityLevel JAVA_25")); + } + + @Test + public void rejectsMixinCompatibilityLevelAboveJava21RegisteredByNeoforgeToml() throws Exception { + Map entries = new LinkedHashMap<>(); + entries.put(NEOFORGE_METADATA, ("[[mixins]]\nconfig = \"" + CLIENT_MIXIN_CONFIG + "\"\n") + .getBytes(StandardCharsets.UTF_8)); + entries.put(CODEC_CLASS, INTERNAL_CODEC); + entries.put(HARDWARE_CLASS, "oshi/SystemInfo".getBytes(StandardCharsets.ISO_8859_1)); + entries.put(CLIENT_MIXIN_CONFIG, mixinConfig("JAVA_25")); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verify(artifact, List.of(CODEC_CLASS, HARDWARE_CLASS))); + assertTrue(failure.getMessage().contains("compatibilityLevel JAVA_25")); + } + + @Test + public void rejectsRegisteredMixinConfigThatIsNotShipped() throws Exception { + Map entries = validEntries(); + entries.remove(MIXIN_CONFIG); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES)); + assertTrue(failure.getMessage().contains("which is not in the jar")); + } + + @Test + public void rejectsArtifactWithoutMixinRegistration() throws Exception { + Map entries = validEntries(); + entries.put(METADATA, fabricMetadata("[ ]", "[ ]")); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES)); + assertTrue(failure.getMessage().contains("registers no mixin configs")); + } + + @Test + public void rejectsBukkitSupertypeOutsidePlatformPackage() throws Exception { + Map entries = validEntries(); + entries.put("art/arcane/iris/engine/platform/BukkitChunkGenerator.class", + classExtending("art/arcane/iris/engine/platform/BukkitChunkGenerator", + "org/bukkit/generator/ChunkGenerator")); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES)); + assertTrue(failure.getMessage().contains("org.bukkit supertype")); + } + + @Test + public void rejectsBukkitInterfaceOutsidePlatformPackage() throws Exception { + Map entries = validEntries(); + entries.put("art/arcane/iris/Listener.class", + classImplementing("art/arcane/iris/Listener", "org/bukkit/event/Listener")); + File artifact = createArtifact(entries); + + GradleException failure = assertThrows(GradleException.class, + () -> ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES)); + assertTrue(failure.getMessage().contains("org.bukkit supertype")); + } + + @Test + public void acceptsBukkitSupertypeInsidePlatformPackage() throws Exception { + Map entries = validEntries(); + entries.put("art/arcane/iris/platform/bukkit/BukkitWorld.class", + classImplementing("art/arcane/iris/platform/bukkit/BukkitWorld", "org/bukkit/event/Listener")); + File artifact = createArtifact(entries); + + ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES); + } + + @Test + public void acceptsBaselinedBukkitSupertype() throws Exception { + String entryName = "art/arcane/iris/engine/platform/BukkitChunkGenerator.class"; + Map entries = validEntries(); + entries.put(entryName, classExtending("art/arcane/iris/engine/platform/BukkitChunkGenerator", + "org/bukkit/generator/ChunkGenerator")); + File artifact = createArtifact(entries); + + ModdedArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, Set.of(entryName)); + } + private Map validEntries() { Map entries = new LinkedHashMap<>(); - entries.put(METADATA, new byte[]{0}); + entries.put(METADATA, fabricMetadata("[ ]", "[ \"" + MIXIN_CONFIG + "\" ]")); + entries.put(MIXIN_CONFIG, mixinConfig("JAVA_21")); entries.put(CODEC_CLASS, INTERNAL_CODEC); entries.put(HARDWARE_CLASS, "oshi/SystemInfo".getBytes(StandardCharsets.ISO_8859_1)); return entries; } + private byte[] fabricMetadata(String jarsArray, String mixinsArray) { + return ("{\n" + + " \"schemaVersion\": 1,\n" + + " \"id\": \"irisworldgen\",\n" + + " \"mixins\": " + mixinsArray + ",\n" + + " \"jars\": " + jarsArray + "\n" + + "}\n").getBytes(StandardCharsets.UTF_8); + } + + private byte[] mixinConfig(String compatibilityLevel) { + return ("{\n" + + " \"required\": true,\n" + + " \"minVersion\": \"0.8\",\n" + + " \"package\": \"art.arcane.iris.modded.mixin\",\n" + + " \"compatibilityLevel\": \"" + compatibilityLevel + "\"\n" + + "}\n").getBytes(StandardCharsets.UTF_8); + } + + private Manifest manifestWithMixinConfigs(String configs) { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().putValue("MixinConfigs", configs); + return manifest; + } + + private byte[] classExtending(String internalName, String superName) { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, internalName, null, superName, null); + writer.visitEnd(); + return writer.toByteArray(); + } + + private byte[] classImplementing(String internalName, String interfaceName) { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", + new String[]{interfaceName}); + writer.visitEnd(); + return writer.toByteArray(); + } + private File createArtifact(Map entries) throws Exception { + return createArtifact(entries, null); + } + + private File createArtifact(Map entries, Manifest manifest) throws Exception { File artifact = temporaryFolder.newFile("artifact-" + System.nanoTime() + ".jar"); - try (JarOutputStream output = new JarOutputStream(new FileOutputStream(artifact))) { + try (FileOutputStream file = new FileOutputStream(artifact); + JarOutputStream output = manifest == null + ? new JarOutputStream(file) + : new JarOutputStream(file, manifest)) { for (Map.Entry entry : entries.entrySet()) { output.putNextEntry(new JarEntry(entry.getKey())); output.write(entry.getValue()); diff --git a/core/build.gradle b/core/build.gradle index e77aae3e3..0c973d6b5 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -214,6 +214,12 @@ tasks.named('compileJava', JavaCompile).configure { options.compilerArgs.addAll(['--add-modules', 'jdk.incubator.vector']) options.encoding = 'UTF-8' options.debugOptions.debugLevel = 'none' + // lombok.config decides how equals/hashCode/toString are generated (fields, never getters), so editing it + // changes this module's bytecode. Gradle does not know that on its own and would serve a stale up-to-date + // build until something else in the module changed. + inputs.file(rootProject.file('lombok.config')) + .withPropertyName('lombokConfig') + .withPathSensitivity(PathSensitivity.NONE) } tasks.named('test', Test).configure { diff --git a/core/purity-allowlist.txt b/core/purity-allowlist.txt index 68465ca64..315ff0339 100644 --- a/core/purity-allowlist.txt +++ b/core/purity-allowlist.txt @@ -112,8 +112,6 @@ art/arcane/iris/engine/object/IrisPotionEffect.java art/arcane/iris/engine/object/IrisSurface.java art/arcane/iris/engine/object/IrisTree.java art/arcane/iris/engine/object/IrisVanillaLootTable.java -art/arcane/iris/engine/object/IrisVillagerOverride.java -art/arcane/iris/engine/object/IrisVillagerTrade.java art/arcane/iris/engine/object/LegacyTileData.java art/arcane/iris/engine/object/PotionEffectTypes.java art/arcane/iris/engine/object/TileData.java diff --git a/core/src/main/java/art/arcane/iris/core/IrisSettings.java b/core/src/main/java/art/arcane/iris/core/IrisSettings.java index 706d4fd00..49295de4d 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisSettings.java +++ b/core/src/main/java/art/arcane/iris/core/IrisSettings.java @@ -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; diff --git a/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java b/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java index 74db79c48..72b2947ca 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java +++ b/core/src/main/java/art/arcane/iris/core/gui/GuiHost.java @@ -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; + } } diff --git a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java index 080941760..a3ba9bb91 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java +++ b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java @@ -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(() -> { diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisRegistrant.java b/core/src/main/java/art/arcane/iris/core/loader/IrisRegistrant.java index abd061bfa..35aac5081 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisRegistrant.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisRegistrant.java @@ -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); } diff --git a/core/src/main/java/art/arcane/iris/core/localization/ClientUiMessages.java b/core/src/main/java/art/arcane/iris/core/localization/ClientUiMessages.java index 627b94774..518461e8d 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/ClientUiMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/ClientUiMessages.java @@ -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, diff --git a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java index 4c7ef1b8d..3ac28c418 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java +++ b/core/src/main/java/art/arcane/iris/core/localization/IrisLanguage.java @@ -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 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 values) { + } } diff --git a/core/src/main/java/art/arcane/iris/core/localization/ModdedCommandMessages.java b/core/src/main/java/art/arcane/iris/core/localization/ModdedCommandMessages.java index b965d5d97..5ad8c3a2e 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/ModdedCommandMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/ModdedCommandMessages.java @@ -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", diff --git a/core/src/main/java/art/arcane/iris/core/pack/ContentKeyValidator.java b/core/src/main/java/art/arcane/iris/core/pack/ContentKeyValidator.java index 08f89e587..7d34f66c9 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/ContentKeyValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/ContentKeyValidator.java @@ -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. + *

+ * 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 validateBlockStateProperties(PlatformRegistries registries, + Collection referencedBlockStates) { + if (registries == null || referencedBlockStates == null || referencedBlockStates.isEmpty()) { + return List.of(); + } + Map> declared = registries.blockStateProperties(); + if (declared == null || declared.isEmpty()) { + return List.of(); + } + + List messages = new ArrayList<>(); + Set 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 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 known) { + PlatformBlockProperty match = null; + List 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 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 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 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 strict, List 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()}). + *

+ * 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 strict = new ArrayList<>(); + List advisory = new ArrayList<>(); try { if (!IrisPlatforms.isBound()) { - return; + return ContentKeyIssues.none(); } PlatformRegistries registries = IrisPlatforms.get().registries(); if (registries == null) { - return; + return ContentKeyIssues.none(); } List blockKeys = registries.blockKeys(); List itemKeys = registries.itemKeys(); List 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 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 keys) { + return keys == null ? 0 : keys.size(); } private static ReferencedContentKeys collectReferencedContentKeys(File packFolder) { Set blocks = new HashSet<>(); Set items = new HashSet<>(); Set entities = new HashSet<>(); + Set blockStates = new HashSet<>(); Set customBlocks = deriveRegistrantKeys(new File(packFolder, "blocks")); try (Stream 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 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 paletteOnly = new HashSet<>(blocks); + paletteOnly.removeAll(jsonBlocks); + + return new ReferencedContentKeys(blocks, items, entities, blockStates, paletteOnly); } - private static void collectFromNode(Object node, Set blocks, Set items, Set entities, Set 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 blocks, Set blockStates, Set customBlocks) { + if (!objectsFolder.isDirectory()) { + return; + } + int scanned = 0; + try (Stream stream = Files.walk(objectsFolder.toPath())) { + List 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 blocks, Set items, Set entities, Set customBlocks, Set 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 blocks, Set customBlocks) { + private static void addBlockRef(String raw, Set blocks, Set customBlocks, Set 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 target) { @@ -376,6 +619,11 @@ public final class ContentKeyValidator { return keys; } - private record ReferencedContentKeys(Set blocks, Set items, Set 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 blocks, Set items, Set entities, + Set blockStates, Set paletteOnlyBlocks) { } } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index 9bd56a3b5..25bdddfa3 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java @@ -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); } diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java index 84f9e4ce2..ef9bc05ef 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/PregenTask.java @@ -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 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); diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisProjectCleaner.java b/core/src/main/java/art/arcane/iris/core/project/IrisProjectCleaner.java deleted file mode 100644 index 9f15dc2b3..000000000 --- a/core/src/main/java/art/arcane/iris/core/project/IrisProjectCleaner.java +++ /dev/null @@ -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 . - */ - -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 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 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); - } - } - } -} diff --git a/core/src/main/java/art/arcane/iris/core/project/IrisProjectCompiler.java b/core/src/main/java/art/arcane/iris/core/project/IrisProjectCompiler.java deleted file mode 100644 index bfe04823c..000000000 --- a/core/src/main/java/art/arcane/iris/core/project/IrisProjectCompiler.java +++ /dev/null @@ -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 . - */ - -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 jobs = new KList<>(); - KList files = new KList<>(); - KList objects = new KList<>(); - IrisProjectCleaner.files(project.getPath(), files); - IrisProjectCleaner.filesObjects(project.getPath(), objects); - - jobs.add(new ParallelQueueJob() { - @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() { - @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 path) { - try { - Object o = c.getClass().getConstructor().newInstance(); - } catch (Throwable e) { - - } - } -} diff --git a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java index fa235e21c..207d56ba2 100644 --- a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java +++ b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java @@ -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 definitions; private final Class root; private final KList 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 keys, boolean upperCaseLegacy) { + JSONArray a = new JSONArray(); + Set 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 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. + *

+ * 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 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 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 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>> 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"; - 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"; - 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"; - 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>> functionClass = k.getDeclaredAnnotation(RegistryListFunction.class).value(); diff --git a/core/src/main/java/art/arcane/iris/core/protocol/IrisProtocolServer.java b/core/src/main/java/art/arcane/iris/core/protocol/IrisProtocolServer.java index 8e540add7..04e1f74f7 100644 --- a/core/src/main/java/art/arcane/iris/core/protocol/IrisProtocolServer.java +++ b/core/src/main/java/art/arcane/iris/core/protocol/IrisProtocolServer.java @@ -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; + } } diff --git a/core/src/main/java/art/arcane/iris/core/protocol/IrisSession.java b/core/src/main/java/art/arcane/iris/core/protocol/IrisSession.java index c219b0617..abdd499da 100644 --- a/core/src/main/java/art/arcane/iris/core/protocol/IrisSession.java +++ b/core/src/main/java/art/arcane/iris/core/protocol/IrisSession.java @@ -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)); } diff --git a/core/src/main/java/art/arcane/iris/core/protocol/IrisVisionRequestService.java b/core/src/main/java/art/arcane/iris/core/protocol/IrisVisionRequestService.java index ad0adf900..f74b4928b 100644 --- a/core/src/main/java/art/arcane/iris/core/protocol/IrisVisionRequestService.java +++ b/core/src/main/java/art/arcane/iris/core/protocol/IrisVisionRequestService.java @@ -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 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 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) { diff --git a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java index 6c5d6e5a0..232348545 100644 --- a/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java +++ b/core/src/main/java/art/arcane/iris/engine/EngineHotloader.java @@ -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); } } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index 76d49145e..b54fb5b64 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -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. + *

+ * 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; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/NativeFeatureGenerationPolicy.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeFeatureGenerationPolicy.java new file mode 100644 index 000000000..6cc54244f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeFeatureGenerationPolicy.java @@ -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 . + */ + +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. + *

+ * 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; + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index 2db001f8b..69ad518aa 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -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 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 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; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java index 75c87d735..2e61d0b3f 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java @@ -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. + * + *

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 getEffectiveTags(String vanillaDerivativeKey) { + KList resolved = new KList<>(); + KList 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()) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomParticle.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomParticle.java index 61cc3b037..4e8cfb046 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomParticle.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomParticle.java @@ -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); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawn.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawn.java index 022822728..1354e3430 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawn.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustomSpawn.java @@ -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 typeResolved = new AtomicCache<>(); @Required + @RegistryListEntityType @Desc("The biome's entity type") private String type = "minecraft:cow"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java index 3acae87b5..2bbaa2c69 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java @@ -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) { - - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDecorationStep.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDecorationStep.java new file mode 100644 index 000000000..d6bd443ac --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDecorationStep.java @@ -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 . + */ + +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; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index 6b6ffe05a..8d2ace69f 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -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 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 datapackImports = new KList<>(); @@ -535,11 +536,22 @@ public class IrisDimension extends IrisRegistrant { public void installBiomes(IDataFixer fixer, DataProvider data, KList datapackRoots, String namespace, String pathPrefix, KSet 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> 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 datapackRoots) { @@ -579,7 +596,11 @@ public class IrisDimension extends IrisRegistrant { } } - static void installBiomeTags(File datapackRoot, String biomeKey, KList 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> tagMembership, String biomeKey, KList 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> tagMembership) throws IOException { + if (tagMembership == null || tagMembership.isEmpty()) { + return; + } + for (Map.Entry> 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 biomeKeys) throws IOException { + if (biomeKeys == null || biomeKeys.isEmpty()) { + return; + } synchronized (IrisDimension.class) { Set 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 datapackRoots, DimensionHeight height, diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEnchantment.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEnchantment.java index c138f74ca..f0eda8d44 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEnchantment.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEnchantment.java @@ -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()); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java index 04108743d..7310db226 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java @@ -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) { - - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisExpression.java b/core/src/main/java/art/arcane/iris/engine/object/IrisExpression.java index 5b6e55799..c56b16039 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisExpression.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisExpression.java @@ -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) { - - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisGenerator.java index 93d0d8d6c..f85726986 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisGenerator.java @@ -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) { - - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java index 868126742..b2aa8d026 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImage.java @@ -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) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImportedFeatureControl.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImportedFeatureControl.java new file mode 100644 index 000000000..dadfd23be --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImportedFeatureControl.java @@ -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 . + */ + +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 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 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 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 allowed = Objects.requireNonNull( + steps, "importedFeatures.steps must not be null"); + KList 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 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; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java index 557d6cac5..2a59611c7 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java @@ -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) { - - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java index 226506991..437437fb8 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java @@ -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) { - - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisLoot.java b/core/src/main/java/art/arcane/iris/engine/object/IrisLoot.java index 533c859bc..c2e06ac55 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisLoot.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisLoot.java @@ -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; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java b/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java index 13ae7685d..692f18b55 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java @@ -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) { - - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisMarker.java b/core/src/main/java/art/arcane/iris/engine/object/IrisMarker.java index 6008abc9b..4446e5194 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisMarker.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisMarker.java @@ -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) { - - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisMod.java b/core/src/main/java/art/arcane/iris/engine/object/IrisMod.java index 9232a0e84..2a4c2cef1 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisMod.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisMod.java @@ -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) { - - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java index 2c037a9c4..768cb2688 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java @@ -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) { - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java index ca2315b0a..6b87a8019 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java @@ -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. + *

+ * 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 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 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()); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java index fe635091b..d648f6f5b 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java @@ -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); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java index a3846ff22..524059c96 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java @@ -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) { - - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java index 982200bd6..ffcea85ac 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java @@ -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) { - - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java index 028f6babd..747bac401 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java @@ -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) { - - } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaBiomeTags.java b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaBiomeTags.java new file mode 100644 index 000000000..8e7cb261a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaBiomeTags.java @@ -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 . + */ + +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. + * + *

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. + * + *

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. + * + *

{@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. + * + *

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> 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 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 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"); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaLootTable.java b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaLootTable.java index 0247c2452..1e87a18f5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaLootTable.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaLootTable.java @@ -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. + *

+ * 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 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 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 diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVillagerOverride.java b/core/src/main/java/art/arcane/iris/engine/object/IrisVillagerOverride.java deleted file mode 100644 index d3e8cc9af..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisVillagerOverride.java +++ /dev/null @@ -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 . - */ - -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 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 getValidItems() { - KList valid = new KList<>(); - getItems().stream().filter(IrisVillagerTrade::isValidItems).forEach(valid::add); - return valid.size() == 0 ? null : valid; - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVillagerTrade.java b/core/src/main/java/art/arcane/iris/engine/object/IrisVillagerTrade.java deleted file mode 100644 index e0fae3011..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisVillagerTrade.java +++ /dev/null @@ -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 . - */ - -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:
- * ingredient 1 & result are non-null,
- * mintrades > 0, maxtrades > 0, maxtrades > mintrades, and
- * ingredient 1, (if defined ingredient 2) and the result are valid items - */ - public boolean isValidItems() { - KList 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 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; - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/LegacyTileData.java b/core/src/main/java/art/arcane/iris/engine/object/LegacyTileData.java index e11b55cea..bf4458639 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/LegacyTileData.java +++ b/core/src/main/java/art/arcane/iris/engine/object/LegacyTileData.java @@ -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(); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/NativeFeatureGenerationStatus.java b/core/src/main/java/art/arcane/iris/engine/object/NativeFeatureGenerationStatus.java new file mode 100644 index 000000000..cb8fba394 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/NativeFeatureGenerationStatus.java @@ -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 . + */ + +package art.arcane.iris.engine.object; + +public enum NativeFeatureGenerationStatus { + GENERATE_NATIVE, + FEATURES_DISABLED, + STEP_DISABLED, + DISABLED_BY_PACK, + CYCLE_DEGRADED, + INVALID_REGISTRY_KEY +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/TileData.java b/core/src/main/java/art/arcane/iris/engine/object/TileData.java index ba09d9611..cea2c1051 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/TileData.java +++ b/core/src/main/java/art/arcane/iris/engine/object/TileData.java @@ -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. + *

+ * 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 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. + *

+ * 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 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); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListBiome.java b/core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListBiome.java new file mode 100644 index 000000000..538c1b367 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/annotations/RegistryListBiome.java @@ -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 . + */ + +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 { + +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/matter/IrisMatterObject.java b/core/src/main/java/art/arcane/iris/engine/object/matter/IrisMatterObject.java index 6a2c2ee47..4df8191a2 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/matter/IrisMatterObject.java +++ b/core/src/main/java/art/arcane/iris/engine/object/matter/IrisMatterObject.java @@ -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) { - - } } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java index 0bf988c0b..19bc64dd3 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java @@ -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); } }); diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockResolution.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockResolution.java index 461623353..bf6bdf9ee 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockResolution.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockResolution.java @@ -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); } diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java index ecdfa503d..b265a10e2 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitRegistries.java @@ -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 specialEntityKeys() { + ExternalDataSVC external = IrisServices.getOrNull(ExternalDataSVC.class); + if (external == null) { + return List.of(); + } + List keys = new ArrayList<>(); + for (Identifier identifier : external.getAllIdentifiers(DataType.ENTITY)) { + keys.add(identifier.toString()); + } + return keys; + } + @Override public List enchantmentKeys() { List keys = new ArrayList<>(); diff --git a/core/src/main/java/art/arcane/iris/util/common/data/registry/Particles.java b/core/src/main/java/art/arcane/iris/util/common/data/registry/Particles.java index 6f1386bc9..0ba2c2fd5 100644 --- a/core/src/main/java/art/arcane/iris/util/common/data/registry/Particles.java +++ b/core/src/main/java/art/arcane/iris/util/common/data/registry/Particles.java @@ -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; + } + } } diff --git a/core/src/main/java/art/arcane/iris/util/common/misc/Bindings.java b/core/src/main/java/art/arcane/iris/util/common/misc/Bindings.java index 4175776c7..641b1978a 100644 --- a/core/src/main/java/art/arcane/iris/util/common/misc/Bindings.java +++ b/core/src/main/java/art/arcane/iris/util/common/misc/Bindings.java @@ -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) diff --git a/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java b/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java index cad6467aa..f0e9ecff6 100644 --- a/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java +++ b/core/src/main/java/art/arcane/iris/util/common/misc/WebCache.java @@ -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. + * + *

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 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; + } + } } diff --git a/core/src/main/java/art/arcane/iris/util/common/reflect/KeyedType.java b/core/src/main/java/art/arcane/iris/util/common/reflect/KeyedType.java index 983238035..c712cd0fd 100644 --- a/core/src/main/java/art/arcane/iris/util/common/reflect/KeyedType.java +++ b/core/src/main/java/art/arcane/iris/util/common/reflect/KeyedType.java @@ -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; } diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index 037f102b4..22e6f31a7 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -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", diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index 06421824b..b907396ec 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -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", diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index 8aa55d08b..6b64aa1bb 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -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", diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index 7bf5b5a0a..dec706ebe 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -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", diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index ae9f64c8d..7e4416e07 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -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", diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index 78b3f2cf1..6100d6568 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -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", diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index dd8e22377..2c2347a0f 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -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", diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index 0ae09a2d5..3c0da8a99 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -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", diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index 40fcfe8f5..5f18952a4 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -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", diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index d19ed9ae8..763da2a6a 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -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", diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index 53d19a098..b4894434d 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -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", diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index 9feb5f999..4e0d8f386 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -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", diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index 59846f880..28eb9f374 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -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", diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index 6d2c684ee..965d564e1 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -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", diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index fc58c8e44..dd99c765b 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -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", diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index 5bfb3aea2..858cb57dc 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -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", diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index 5565b003b..ca7a01ecc 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -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", diff --git a/core/src/test/java/art/arcane/iris/core/gui/GuiHostServerGuiLaunchTest.java b/core/src/test/java/art/arcane/iris/core/gui/GuiHostServerGuiLaunchTest.java new file mode 100644 index 000000000..18975de5d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/GuiHostServerGuiLaunchTest.java @@ -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)); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/ContentKeyValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/ContentKeyValidatorTest.java index 9ebaaf823..09c332568 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/ContentKeyValidatorTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/ContentKeyValidatorTest.java @@ -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 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 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 blocks, List items, List entities) implements PlatformRegistries { + @Test + public void validateBlockStatePropertiesFlagsUnknownPropertyWithSuggestion() { + List 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 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 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 blocks, List items, List entities, + Map> properties) implements PlatformRegistries { @Override public PlatformBlockState block(String key) { return null; @@ -172,7 +290,7 @@ public class ContentKeyValidatorTest { @Override public Map> blockStateProperties() { - return Map.of(); + return properties; } } } diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskBoundsOverflowTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskBoundsOverflowTest.java new file mode 100644 index 000000000..f9b0b4233 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/PregenTaskBoundsOverflowTest.java @@ -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]); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/ImportedFeatureControlSchemaTest.java b/core/src/test/java/art/arcane/iris/core/project/ImportedFeatureControlSchemaTest.java new file mode 100644 index 000000000..e815ba87f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/ImportedFeatureControlSchemaTest.java @@ -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 . + */ + +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 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 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(); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java index d8ba1a5b7..6f3574812 100644 --- a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java @@ -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 ITEM_KEYS = List.of("minecraft:stone", "minecraft:diamond_sword", "cool_mod:ruby"); private static final List ENTITY_KEYS = List.of("minecraft:zombie", "cool_mod:grizzly_bear"); private static final List STRUCTURE_KEYS = List.of("minecraft:monument", "minecraft:stronghold", "cool_mod:sky_temple"); + private static final List BIOME_KEYS = List.of("minecraft:plains", "cool_mod:sky_meadow"); + private static final List SPECIAL_ENTITY_KEYS = List.of("mythicmobs:skeleton_king"); - private static final List EXPECTED_POTIONS = List.of("SPEED", "SLOW_FALLING", "MEGA_BOOST"); - private static final List 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 EXPECTED_POTIONS = List.of( + "minecraft:speed", "SPEED", "minecraft:slow_falling", "SLOW_FALLING", "sniffer_mod:mega_boost"); + private static final List EXPECTED_ENCHANTS = List.of( + "minecraft:sharpness", "sharpness", "cool_mod:vorpal"); + private static final List EXPECTED_BIOMES = List.of("minecraft:plains", "plains", "cool_mod:sky_meadow"); private static final List EXPECTED_ITEMS = List.of("stone", "diamond_sword", "cool_mod:ruby"); private static final List 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 biomeScatter = new KList<>(); + + @Desc("Special entity field.") + @RegistryListSpecialEntity + private String specialEntity = ""; } @Desc("Independent model.") @@ -397,7 +421,12 @@ public class SchemaBuilderParityTest { @Override public List biomeKeys() { - return List.of(); + return BIOME_KEYS; + } + + @Override + public List specialEntityKeys() { + return SPECIAL_ENTITY_KEYS; } @Override diff --git a/core/src/test/java/art/arcane/iris/core/protocol/IrisProtocolMessageCoverageTest.java b/core/src/test/java/art/arcane/iris/core/protocol/IrisProtocolMessageCoverageTest.java new file mode 100644 index 000000000..7e1516402 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/protocol/IrisProtocolMessageCoverageTest.java @@ -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 . + */ + +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. + * + *

Producers are checked structurally, from the constant pool: a {@code Methodref} naming + * {@code IrisMessage$X.}. 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 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> SERVER_PRODUCERS = List.of( + IrisProtocolServer.class, + IrisCursorResolver.class, + IrisTileEncoder.class); + private static final Set 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 PRODUCER_DEFERRED_TYPES = Set.of(IrisProtocol.TYPE_VISION_MARKERS); + + @Test + public void everyTypeConstantIsClaimedByExactlyOneRecord() { + Map declared = declaredTypeConstants(); + Map 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 permitted = new LinkedHashSet<>(); + for (Class subtype : IrisMessage.class.getPermittedSubclasses()) { + permitted.add(subtype.getSimpleName()); + } + Set 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 constructed = new LinkedHashSet<>(); + for (Class producer : SERVER_PRODUCERS) { + constructed.addAll(constructedMessageRecords(producer)); + } + List missing = new ArrayList<>(); + List 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 declaredTypeConstants() { + Map 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 } and whose owner + * is an IrisMessage record. Bytes only, no class loading, same reason as the core purity gate. + */ + private static Set 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 constructed = new LinkedHashSet<>(); + for (int[] methodref : pool.methodrefs()) { + String owner = pool.className(methodref[0]); + if (owner == null || !owner.startsWith(MESSAGE_OWNER_PREFIX)) { + continue; + } + if (!"".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 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 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())); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/protocol/IrisProtocolServerTest.java b/core/src/test/java/art/arcane/iris/core/protocol/IrisProtocolServerTest.java index 90c7a97ee..1245e6020 100644 --- a/core/src/test/java/art/arcane/iris/core/protocol/IrisProtocolServerTest.java +++ b/core/src/test/java/art/arcane/iris/core/protocol/IrisProtocolServerTest.java @@ -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 diff --git a/core/src/test/java/art/arcane/iris/core/protocol/IrisVisionRequestServiceTest.java b/core/src/test/java/art/arcane/iris/core/protocol/IrisVisionRequestServiceTest.java index 4b2e25bf3..d9c5f261c 100644 --- a/core/src/test/java/art/arcane/iris/core/protocol/IrisVisionRequestServiceTest.java +++ b/core/src/test/java/art/arcane/iris/core/protocol/IrisVisionRequestServiceTest.java @@ -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); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeFeatureGenerationPolicyTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeFeatureGenerationPolicyTest.java new file mode 100644 index 000000000..727c35aad --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeFeatureGenerationPolicyTest.java @@ -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 . + */ + +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 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()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomTagInheritanceTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomTagInheritanceTest.java new file mode 100644 index 000000000..c4eb6a9b8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomTagInheritanceTest.java @@ -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 . + */ + +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 tags(String... values) { + KList 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 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 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 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()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionBiomeTagTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionBiomeTagTest.java index b12712c1b..a4e16ea7b 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionBiomeTagTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionBiomeTagTest.java @@ -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 tags = new KList<>(); tags.add("minecraft:allows_surface_slime_spawns"); - IrisDimension.installBiomeTags(temporaryFolder.getRoot(), "overworld:swamp", tags); + KMap> 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 tags = new KList<>(); + tags.add("minecraft:is_overworld"); + KMap> first = new KMap<>(); + IrisDimension.collectBiomeTags(first, "overworld:swamp", tags); + IrisDimension.collectBiomeTags(first, "overworld:plains", tags); + IrisDimension.installBiomeTags(temporaryFolder.getRoot(), first); + + KMap> 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)); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisImportedFeatureControlTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisImportedFeatureControlTest.java new file mode 100644 index 000000000..7e0baa13c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisImportedFeatureControlTest.java @@ -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 . + */ + +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 keys(String... values) { + KList list = new KList<>(); + for (String value : values) { + list.add(value); + } + return list; + } + + private static KList steps(IrisDecorationStep... values) { + KList 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)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisLootKeyAccessorTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisLootKeyAccessorTest.java new file mode 100644 index 000000000..1d07f3339 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisLootKeyAccessorTest.java @@ -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 . + */ + +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()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPaletteScanTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPaletteScanTest.java new file mode 100644 index 000000000..b4aa49426 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPaletteScanTest.java @@ -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 . + */ + +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; + } +} diff --git a/core/src/test/java/art/arcane/iris/purity/BukkitHidingClassLoader.java b/core/src/test/java/art/arcane/iris/purity/BukkitHidingClassLoader.java new file mode 100644 index 000000000..fe4e072b5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/purity/BukkitHidingClassLoader.java @@ -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. + *

+ * Two properties make it a real gate rather than a decoration: + *

    + *
  1. Bukkit and Paper are refused outright - the app classloader is never consulted, so + * parent-first delegation cannot leak Paper in through the back door.
  2. + *
  3. Every {@code art.arcane.iris.**} class (except this test-support package) and every + * {@code art.arcane.volmlib.**} class is defined by this loader 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.
  4. + *
+ * 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. + *

+ * 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; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/purity/ClassFileFacts.java b/core/src/test/java/art/arcane/iris/purity/ClassFileFacts.java new file mode 100644 index 000000000..cff0e19cc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/purity/ClassFileFacts.java @@ -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. + *

+ * 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. + *

+ * 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} 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 interfaceNames; + private final List fields; + private final Set classAnnotationDescriptors; + + private ClassFileFacts(String internalName, + String superName, + List interfaceNames, + List fields, + Set 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 interfaceNames() { + return interfaceNames; + } + + public List 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 interfaceNames = new ArrayList<>(interfaceCount); + for (int i = 0; i < interfaceCount; i++) { + interfaceNames.add(utf8[classNameIndex[in.readUnsignedShort()]]); + } + + int fieldCount = in.readUnsignedShort(); + List 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 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 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 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); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/purity/PackTypeBukkitPurityGateTest.java b/core/src/test/java/art/arcane/iris/purity/PackTypeBukkitPurityGateTest.java new file mode 100644 index 000000000..0fe4ba6e4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/purity/PackTypeBukkitPurityGateTest.java @@ -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. + *

+ * 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: + *

    + *
  • 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}.
  • + *
  • 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.
  • + *
+ * 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. + *

+ * Observed pre-fix failures (WP4 baseline): {@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). + *

+ * Deliberately not asserted: 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 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 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 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 inSource = new TreeSet<>(); + while (matcher.find()) { + inSource.add(matcher.group(1)); + } + Set 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 failures = new ArrayList<>(); + for (String name : BUKKIT_STATIC_HOLDERS) { + try { + Class.forName(name, true, hiding); + } catch (Throwable e) { + if (mentionsBukkit(e)) { + failures.add(name + "#: " + describe(e)); + } + } + } + assertPure(failures); + } + + @Test + public void packTypesLoadAndBehaveWithoutBukkit() throws Exception { + BukkitHidingClassLoader hiding = new BukkitHidingClassLoader(getClass().getClassLoader()); + List gateTypes = gateTypes(); + assertTrue("expected the @Snippet scan to find the pack snippet types, found only " + + gateTypes.size(), gateTypes.size() > 60); + + List failures = new ArrayList<>(); + List skipped = new ArrayList<>(); + + for (String name : gateTypes) { + Class type; + try { + type = Class.forName(name, true, hiding); + } catch (Throwable e) { + record(failures, skipped, name + "#", 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 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 + "#", 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 and 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. + *

+ * {@code IrisObjectRotation$Faces} is a private lazy holder whose initializer reads + * {@code org.bukkit.block.BlockFace} constants. It is deliberately not 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 failures = new ArrayList<>(); + List 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 + "#", 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 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 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 failures, List skipped, String member, Throwable e) { + if (mentionsBukkit(e)) { + failures.add(member + ": " + describe(e)); + } else { + skipped.add(member + ": " + describe(e)); + } + } + + private static void assertPure(List 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 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 gateTypes() throws Exception { + Set types = new LinkedHashSet<>(GSON_REGISTERED_ROOTS); + types.addAll(ADDITIONAL_PACK_TYPES); + types.addAll(snippetTypes()); + return new ArrayList<>(types); + } + + private static List snippetTypes() throws Exception { + ClassLoader app = PackTypeBukkitPurityGateTest.class.getClassLoader(); + List 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 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 names = new ArrayList<>(); + + if (Files.isDirectory(root)) { + Path directory = root.resolve(packageInternalName); + if (!Files.isDirectory(directory)) { + return names; + } + try (Stream 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; + } +} diff --git a/docs/api/modded.md b/docs/api/modded.md index fc88b9e15..26967251b 100644 --- a/docs/api/modded.md +++ b/docs/api/modded.md @@ -6,7 +6,7 @@ placed by an Iris pack. It ships in the Fabric, Forge and NeoForge jars only. It is absent from the Bukkit plugin jar, shares no types with `art.arcane.iris.api`, and is not covered by [terrain.md](terrain.md), [world-events.md](world-events.md) -or [tree-feller.md](tree-feller.md) — those describe the Bukkit surface, which does not exist on a mod loader. +or [tree-feller.md](tree-feller.md) - those describe the Bukkit surface, which does not exist on a mod loader. Everything here assumes Minecraft 26.2, Java 25, and one of Fabric, Forge or NeoForge. The mod id is `irisworldgen` on all three. @@ -25,7 +25,7 @@ Everything here assumes Minecraft 26.2, Java 25, and one of Fabric, Forge or Neo `maven-publish`, and the JitPack route documented in [README.md](README.md) resolves the Bukkit sources, not the modded adapter. Until that changes, building from source is the only path. -The three adapters are standalone Gradle builds — each `adapters//settings.gradle` does +The three adapters are standalone Gradle builds - each `adapters//settings.gradle` does `includeBuild('../..')` to substitute `art.arcane:core` and `art.arcane:spi` from the root build, which is what keeps Loom, ForgeGradle and ModDevGradle off one plugin classpath. The root build drives them through their own wrappers: @@ -49,10 +49,10 @@ import only; it closes a composite build cycle (root -> adapter -> root) and Gra ### Soft dependency -Declare the optional relationship, then do not rely on load order — the ServiceLoader path below works +Declare the optional relationship, then do not rely on load order - the ServiceLoader path below works regardless of it, because Iris does the discovering. -Fabric (`fabric.mod.json`) — `suggests`, not `depends`; a hard `depends` makes Iris mandatory: +Fabric (`fabric.mod.json`) - `suggests`, not `depends`; a hard `depends` makes Iris mandatory: ```json { @@ -76,7 +76,7 @@ Forge (`META-INF/mods.toml`) is the same with `mandatory = false` instead of `ty Two checks, and they answer different questions. -**Is the mod present?** Ask the loader — `FabricLoader.getInstance().isModLoaded("irisworldgen")`, or +**Is the mod present?** Ask the loader - `FabricLoader.getInstance().isModLoaded("irisworldgen")`, or `ModList.get().isLoaded("irisworldgen")` on Forge and NeoForge. Cheap, but tells you nothing about whether Iris actually generates anything. @@ -126,7 +126,7 @@ have to pre-check. ### `Engine` is internal `getEngine` returns `art.arcane.iris.engine.framework.Engine`. That type is **internal to Iris** and changes -without a deprecation cycle — as do `art.arcane.iris.core.*`, `art.arcane.iris.util.*` and +without a deprecation cycle - as do `art.arcane.iris.core.*`, `art.arcane.iris.util.*` and `art.arcane.iris.spi.*`. Treat the returned `Engine` as an opaque token to hand back to Iris. Every method in the table above that needs one resolves it for you; prefer those. @@ -136,7 +136,7 @@ Never cache an `Engine`. A pack hotload or a level unload replaces it, and the o ### Pregeneration `pregenerate` returns as soon as the job is queued. Progress goes to Iris's own logging and boss bar, not to -your caller. Only one job runs server-wide, so it returns `false` if one is already active — and `false` also +your caller. Only one job runs server-wide, so it returns `false` if one is already active - and `false` also means "not an Iris level", so check `isIrisLevel` first if you need to tell those apart. Call it on the server thread. @@ -149,9 +149,9 @@ The mantle is Iris's own per-block storage, independent of chunk NBT, and it is generation stages. Three things to know: 1. **Coordinates are world-space.** `y` is translated by the engine's minimum height internally. A `y` outside - the engine's height range reads as null and writes as a no-op — no exception. + the engine's height range reads as null and writes as a no-op - no exception. 2. **Reads never create storage; writes do.** `getMantleData` returns null when no mantle region exists for - that column yet. `setMantleData` and `deleteMantleData` create the region, which can touch disk — do not + that column yet. `setMantleData` and `deleteMantleData` create the region, which can touch disk - do not call them per block in a tick loop on the server thread. 3. **Declare your types or lose them.** Iris discards mantle slices it does not need once a region's generation data has served its purpose. Any type you write and expect to read back later must be declared @@ -184,7 +184,7 @@ public interface ModdedDataProvider { } ``` -`ModdedDataType` is `BLOCK`, `ITEM` or `ENTITY`. Constants may be added — write a `default` arm in any switch +`ModdedDataType` is `BLOCK`, `ITEM` or `ENTITY`. Constants may be added - write a `default` arm in any switch expression over it. ### The contract @@ -199,7 +199,7 @@ for every key it could not resolve itself. Keep it to a namespace comparison or returns false rather than treating it as absent, so returning false is strictly better than returning wrong answers. -`getTypes(type)` feeds command suggestion and pack tooling. It is not the resolution path — return an empty +`getTypes(type)` feeds command suggestion and pack tooling. It is not the resolution path - return an empty collection rather than null, and do not do work here that `isValidProvider` should do. `getBlockData(blockId, state)` resolves a claimed block. `state` holds the `[prop=value]` pairs from the pack's @@ -207,14 +207,14 @@ key, already parsed, possibly empty, never null. Return null to decline and Iris falls back to air. Return `ModdedBlockData.direct(blockState)` when the state is final. `processBlockPlacement(context)` finishes a **deferred** placement. Return -`ModdedBlockData.deferred(placeholder)` from `getBlockData` when the real block needs a loaded level — a block +`ModdedBlockData.deferred(placeholder)` from `getBlockData` when the real block needs a loaded level - a block entity, neighbour state, or mod registries not reachable from a generation thread. Iris writes your placeholder during generation and calls you back later on the server thread with the chunk loaded. Pick a placeholder with the same shape and occlusion as the final block so terrain around it generates correctly. Only the *first* provider claiming the identifier is called for a given position. `ModdedBlockPlacementContext` is an immutable record: `engine`, `level`, `position`, `blockId`, `state`, -`blockState`. `blockState` is what is currently at `position` — normally your placeholder, though a later +`blockState`. `blockState` is what is currently at `position` - normally your placeholder, though a later generation stage may have replaced it. `state` is defensively copied and unmodifiable. `spawnMob(...)` spawns a claimed custom entity on the server thread. Return null to decline. @@ -257,7 +257,7 @@ fall back to `registerProvider`. ### When discovery runs `ModdedCustomContentRegistry.discover()` runs inside `ModdedEngineBootstrap.bootCommon(...)`, which is the very -first thing each loader's entrypoint calls — `IrisFabricBootstrap.onInitialize`, +first thing each loader's entrypoint calls - `IrisFabricBootstrap.onInitialize`, `IrisForgeBootstrap`/`IrisNeoForgeBootstrap` construction. That is **before** the Iris chunk generator is registered and long before any server starts. Consequences: @@ -279,7 +279,7 @@ A second registration under a `modId()` already present is logged and ignored. ` ### How discovery and failures are reported -Everything below is logged under the `Iris` logger. One line per accepted provider confirms registration — +Everything below is logged under the `Iris` logger. One line per accepted provider confirms registration - this is what to grep for when checking whether your service file was seen: ``` @@ -289,7 +289,7 @@ Iris registered custom content provider 'yourmod' A duplicate `modId()` is rejected with `already registered; ignoring duplicate`. Iris catches throwables from every provider callback, logs them against your `modId()`, and continues with the -remaining providers — one broken provider does not stop world generation: +remaining providers - one broken provider does not stop world generation: ``` Iris custom content provider 'yourmod' failed resolving block yourmod:thing @@ -318,7 +318,7 @@ For the common case of "my key is really this vanilla block", skip the provider: IrisModdedAPI.registerCustomBlockData("yourmod", "fancy_log", "minecraft:oak_log[axis=y]"); ``` -The state string uses the same syntax packs use and is parsed **immediately** — a typo is logged at startup and +The state string uses the same syntax packs use and is parsed **immediately** - a typo is logged at startup and the registration dropped, rather than surfacing later as missing blocks. Aliases take precedence over provider lookups for the same key. Null arguments are ignored. @@ -331,12 +331,12 @@ Paths are relative to the loader's config directory (`config/` on a normal serve | Path | What it is | |---|---| | `config/irisworldgen/packs//` | Installed packs. A pack is valid when `dimensions/.json` exists | -| `config/irisworldgen/generated/datapack/iris/` | The generated forced datapack. Iris owns this — do not edit it | +| `config/irisworldgen/generated/datapack/iris/` | The generated forced datapack. Iris owns this - do not edit it | | `config/irisworldgen/modded.json` | Mod-side config: default pack, auto-download, primary world routing | | `config/iris/` | Engine data directory: settings and per-world engine state | -Note the split: the engine's data folder is `config/iris`, but every modded pack path — installer, validator, -command suggestions, forced datapack, engine creation — resolves under `config/irisworldgen/packs`. Install +Note the split: the engine's data folder is `config/iris`, but every modded pack path - installer, validator, +command suggestions, forced datapack, engine creation - resolves under `config/irisworldgen/packs`. Install packs there. At `bootCommon`, Iris kicks off an async default-pack prefetch. If `modded.json` has @@ -345,7 +345,7 @@ At `bootCommon`, Iris kicks off an async default-pack prefetch. If `modded.json` `/iris download `. A pack that is already installed is left alone. When a level asks for its pack, `ModdedWorldEngines.packFolder(pack)` resolves -`config/irisworldgen/packs/`. A missing pack is a hard failure with the expected absolute path printed — +`config/irisworldgen/packs/`. A missing pack is a hard failure with the expected absolute path printed - Iris does not silently generate vanilla terrain in its place. ### The forced datapack @@ -358,7 +358,7 @@ a built-in, top-priority repository source: - Forge and NeoForge: `event.addRepositorySource(ModdedForcedDatapack.repositorySource())` It contributes world presets, dimension types and biomes under the `irisworldgen` namespace, with ids derived -from the pack and dimension names — `irisworldgen:packs//dimensions//preset`, +from the pack and dimension names - `irisworldgen:packs//dimensions//preset`, `.../dimension_type`, `.../biomes/`. This is why an Iris dimension shows up in the vanilla world creation screen as `IRIS:`. @@ -366,8 +366,8 @@ It is regenerated when the pack changes: a studio hotload calls `ModdedForcedDat regeneration fails and a previously published pack is still readable, Iris keeps the last known-good one and logs the failure rather than starting with no dimension types. -**The failure you need to recognise.** If injection did not happen for your loader — a mixin that failed to -apply, an event that never fired — Iris logs this once at startup and world creation will fail no matter how +**The failure you need to recognise.** If injection did not happen for your loader - a mixin that failed to +apply, an event that never fired - Iris logs this once at startup and world creation will fail no matter how many times you restart: ``` @@ -386,7 +386,7 @@ The modded command tree is `/iris`, aliased `/ir` and `/irs`, gated at gamemaste | Command | What it tells you | |---|---| | `/iris pack validate [pack]` | Validates every installed pack, or one. Runs on a worker thread, reports per pack, and counts unloadable packs | -| `/iris pack status [pack]` | Replays the **recorded** validation results — blocking errors and warnings per pack. Says so and returns nothing if `validate` has not run this session | +| `/iris pack status [pack]` | Replays the **recorded** validation results - blocking errors and warnings per pack. Says so and returns nothing if `validate` has not run this session | | `/iris pack cleanup [apply]` | Previews unused pack resources; `apply` deletes them | | `/iris pack restore [apply]` | Previews a restore of pack resources; `apply` performs it | | `/iris datapack status` | Per Iris dimension: active dimension type, its min/max/logical height, what the pack wants, and whether they match | @@ -401,7 +401,113 @@ created before a pack's height range changed. `install` writes the override; the `/iris datapack ingest`, `pull` and `remove` exist but refuse on modded, with a message explaining why: the Modrinth ingest workflow is Bukkit tooling. Native vanilla and datapack structure placement **does** work on -modded — install the datapack into `/datapacks/` and restart, and its registered structures generate. +modded - install the datapack into `/datapacks/` and restart, and its registered structures generate. + +--- + +## Native worldgen passthrough: what generates over Iris terrain + +Iris replaces the chunk generator outright. Every piece of vanilla or mod worldgen therefore only runs if Iris +runs it. This is the honest matrix. + +| Vanilla / mod worldgen | Runs over Iris terrain? | How | +|---|---|---| +| Structures (vanilla, datapack, mod) | **Yes**, on by default | Iris runs its own structure pass with vertical fitting, foundation stilts and vegetation clearing. Deny with `importedStructures.disabled` | +| Placed features: ores, trees, plants, springs, geodes (vanilla, datapack, mod) | **Yes**, off by default | Set `importedFeatures.enabled` on the dimension | +| Carvers (caves, canyons, mod carvers) | **Never** | Architectural. See below | +| Mod biomes | Only as a `derivative` / `vanillaDerivative` / `biomeScatter` target | Iris chooses biomes from the pack, not from a biome source | +| Mob spawning, including mod mobs | **Yes** | Iris merges the biome's own spawn table with the vanilla derivative's | +| Surface builders / surface rules | **Never** | Iris generates its own surface from the pack palettes | + +### `importedFeatures` + +A dimension-level control block, disabled by default. With it absent or `enabled: false` chunk output is +byte-for-byte what Iris has always produced, and no feature table is built. + +Biome tags are not part of that guarantee, and are not gated on this flag at all: Iris custom biomes inherit +the biome tags of their vanilla derivative on every platform, so the emitted datapack tag files differ from +older Iris builds regardless of `importedFeatures`. Anything driven by biome tags - mob variants, spawn rules, +mod content selecting on `#minecraft:is_overworld` and friends - therefore applies to Iris custom biomes. + +```json +{ + "importedFeatures": { + "enabled": true, + "steps": ["UNDERGROUND_ORES"], + "disabledSteps": ["VEGETAL_DECORATION"], + "disabled": ["minecraft:ore_diamond", "minecraft:trees"] + } +} +``` + +| Field | Meaning | +|---|---| +| `enabled` | Master switch. Default `false` | +| `steps` | Allow-list of decoration steps. Empty (default) means every step | +| `disabledSteps` | Deny-list of decoration steps, applied after `steps` | +| `disabled` | Placed-feature key deny-list. A `namespace:path` prefix matches on namespace, slash and underscore boundaries, so `minecraft:ore` denies every vanilla ore | + +Steps are the vanilla ones, in order: `RAW_GENERATION`, `LAKES`, `LOCAL_MODIFICATIONS`, +`UNDERGROUND_STRUCTURES`, `SURFACE_STRUCTURES`, `STRONGHOLDS`, `UNDERGROUND_ORES`, `UNDERGROUND_DECORATION`, +`FLUID_SPRINGS`, `VEGETAL_DECORATION`, `TOP_LAYER_MODIFICATION`. Ores live in `UNDERGROUND_ORES`; trees, grass +and flowers in `VEGETAL_DECORATION`. + +What you get, and what it costs: + +- Features are read from the biome's **vanilla derivative**. An Iris custom biome declares no features of its + own by design (its generated datapack JSON has empty `features` and `carvers` arrays); passthrough comes from + the chunk generator's generation-settings getter, which maps the custom biome onto the derivative. +- Iris terrain is not vanilla terrain. A feature that assumes a vanilla surface can land oddly - floating + sugar cane, ore veins in unexpected rock, trees on a slope Iris carved. Turn it on per dimension and look + before shipping. +- Feature seeds are derived exactly as vanilla derives them, so the same pack plus the same registries places + the same features. Denying one feature never shifts another: each takes its seed from its own global index. +- The pass runs on the worldgen thread that owns the chunk, never on the Iris generation pool. The vanilla + FEATURES step writes into neighbouring chunks and is not parallel-safe. +- The whole feature pass runs after Iris has placed its structures, not interleaved per step the way vanilla + orders them. An early-step feature - `RAW_GENERATION`, `LAKES`, `LOCAL_MODIFICATIONS` - therefore sees placed + structures and can cut into one, so a lake can open into a structure vanilla would have flooded before + placing it. +- Both platforms behave identically: the Bukkit plugin has the same control with the same semantics. + +**Feature order cycles.** Vanilla topologically sorts every placed feature across every biome. Content that +declares mutually inconsistent orderings makes that sort fail with `Feature order cycle found`. Iris builds the +table at bind and catches that failure: `importedFeatures` degrades to off for the dimension and Iris logs an +ERROR naming the involved sources. It never becomes a chunk-generation crash. + +### Why carvers can never be imported + +A carver runs against `NoiseGeneratorSettings` - the noise router, aquifer state and surface rules of a +`NoiseBasedChunkGenerator`. Iris has none of those; its terrain comes from the pack's own generators and its +caves from the Iris carving system. There is nothing for a vanilla carver to sample, so `applyCarvers` is empty +by design and there is no flag to change that. Use Iris `caves` and `carvings` in the pack instead. + +### 26.2 pack-content note: `pointed_dripstone` and `speleothem` + +26.2 renamed the *feature type* `minecraft:pointed_dripstone` to `minecraft:speleothem`, and +`minecraft:dripstone_cluster` to `minecraft:speleothem_cluster`. Verified against the 26.2 built-in data: + +| Registry | 26.2 key | +|---|---| +| Block (`minecraft:block`) | `minecraft:pointed_dripstone` - **unchanged** | +| Placed feature (`minecraft:worldgen/placed_feature`) | `minecraft:pointed_dripstone` - **unchanged** | +| Configured feature (`minecraft:worldgen/configured_feature`) | `minecraft:pointed_dripstone` - **unchanged** | +| Feature type (`minecraft:worldgen/feature`) | `minecraft:speleothem` - **renamed** | + +So a pack that lists `minecraft:pointed_dripstone` in a palette, an object, or an +`importedFeatures.disabled` entry is still correct - those are block and placed-feature keys. Only content that +names the *feature type* directly, which is a datapack-authoring concern rather than an Iris pack concern, needs +updating. + +### Biome tags + +Generated Iris custom biomes inherit the **biome tag membership of their vanilla derivative**, on top of any +tags the pack declares in `tags`. That is what makes `#minecraft:is_overworld` and mod-authored tag selectors +resolve against Iris terrain; without it a custom biome sits in no tag at all. Structure tags +(`#minecraft:has_structure/*`) are deliberately **not** inherited - Iris resolves native structure placement +through the biome's structure derivative, so inheriting them would place a structure twice. + +Tag files are written with `"replace": false`, so vanilla tags are extended, never replaced. --- @@ -419,5 +525,6 @@ modded — install the datapack into `/datapacks/` and restart, and its r the mod jars. There is no modded terrain-query surface yet. - **No PlaceholderAPI.** [placeholders.md](placeholders.md) is Bukkit-only. - **Datapack ingest is Bukkit-only**, per the command note above. +- **Vanilla carvers and surface rules never run.** See the passthrough matrix above. - **`ModdedCustomContentRegistry`'s resolution methods are Iris internals.** They are public only because the adapter's generation code lives in another package. Go through `IrisModdedAPI`. diff --git a/docs/release-readiness-checklist.md b/docs/release-readiness-checklist.md index 9aabcff9d..4082e37b7 100644 --- a/docs/release-readiness-checklist.md +++ b/docs/release-readiness-checklist.md @@ -459,9 +459,9 @@ Release decision: - [x] Headless classload validation scans all 1,166 compiled core classes, including all 353 nested classfiles; 331 nested classes initialize without server APIs and the remaining 22 match exact reviewed class and dependency-namespace entries. -- [x] Modded worldcheck uses a non-daemon coordinator, stops the server before exiting, and returns nonzero - for internal failure, timeout, interruption, thrown checks, and shutdown failure; its exit contract is - covered by the Fabric shared-source test gate. +- [x] Modded worldcheck uses a daemon coordinator with bounded waits on every server task, stops the server + before exiting, and returns nonzero for internal failure, timeout, interruption, thrown checks, and + shutdown failure; its exit contract is covered by the Fabric shared-source test gate. - [x] Fabric protocol startup tolerates the pre-player-list server phase. - [x] NeoForge registers the shared payload once as bidirectional. - [x] Fabric distributable metadata declares the bundled transitive access-widener. diff --git a/lombok.config b/lombok.config index 6aa51d71e..29909f091 100644 --- a/lombok.config +++ b/lombok.config @@ -1,2 +1,14 @@ -# This file is generated by the 'io.freefair.lombok' Gradle plugin +# Authoritative, checked-in lombok config for every module. The 'io.freefair.lombok' plugin that +# originally emitted this file is NOT applied anywhere in this build (core/build.gradle and +# adapters/bukkit/plugin/build.gradle wire lombok directly via compileOnly + annotationProcessor), +# so nothing regenerates or overwrites it. config.stopBubbling = true + +# Generate equals/hashCode/toString from FIELDS, never from getters. +# Iris pack objects store platform values as neutral String keys and hand-write a getter that +# shadows the Lombok accessor to return a resolved Bukkit type (IrisBiome.getDerivative -> Biome, +# IrisEffect.getParticleEffect -> Particle, IrisBiomeCustomSpawn.getType -> EntityType, +# IrisBiomeCustomParticle.getParticle -> Particle). Getter-based equals/hashCode/toString bake +# those Bukkit types into the generated bytecode and NoClassDefFoundError on Fabric/Forge/NeoForge. +lombok.equalsAndHashCode.doNotUseGetters = true +lombok.toString.doNotUseGetters = true diff --git a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java index d0a84d2ae..8c75e4f9e 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java @@ -591,6 +591,11 @@ public final class StubPlatform implements IrisPlatform { return List.of(); } + @Override + public List specialEntityKeys() { + return List.of(); + } + @Override public List enchantmentKeys() { return List.of(); diff --git a/probe/src/main/java/art/arcane/iris/probe/StubTileData.java b/probe/src/main/java/art/arcane/iris/probe/StubTileData.java index 6364703d4..5e92ea58b 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubTileData.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubTileData.java @@ -230,11 +230,32 @@ public final class StubTileData extends TileData { return blockKey; } + @Override + public String getMaterialKey() { + return blockKey; + } + @Override public KMap getProperties() { return tileProperties; } + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof StubTileData other)) { + return false; + } + return blockKey.equals(other.blockKey) && Arrays.equals(binary, other.binary); + } + + @Override + public int hashCode() { + return 31 * blockKey.hashCode() + Arrays.hashCode(binary); + } + @Override public void toBinary(DataOutputStream output) throws IOException { output.write(binary); diff --git a/probe/src/main/resources/classload-allowlist.tsv b/probe/src/main/resources/classload-allowlist.tsv index 5105a6fa3..d28c8c8cd 100644 --- a/probe/src/main/resources/classload-allowlist.tsv +++ b/probe/src/main/resources/classload-allowlist.tsv @@ -26,9 +26,11 @@ art.arcane.iris.core.link.data.MythicMobsDataProvider$IrisRegionCondition MYTHIC art.arcane.iris.core.link.data.NexoDataProvider BUKKIT_API org.bukkit.block.data.BlockData art.arcane.iris.core.nms.INMS BUKKIT_API org.bukkit.Bukkit art.arcane.iris.core.project.IrisProject BUKKIT_API org.bukkit.entity.Entity +art.arcane.iris.core.runtime.StudioOpenCoordinator BUKKIT_API org.bukkit.generator.WorldInfo art.arcane.iris.core.runtime.WorldRuntimeControlService BUKKIT_API org.bukkit.entity.Entity art.arcane.iris.core.service.BoardSVC BUKKIT_API org.bukkit.event.Listener art.arcane.iris.core.service.BoardSVC$PlayerBoard BUKKIT_API org.bukkit.entity.Entity +art.arcane.iris.core.service.EntityRiseSVC BUKKIT_API org.bukkit.event.Listener art.arcane.iris.core.service.ExternalDataSVC BUKKIT_API org.bukkit.event.Listener art.arcane.iris.core.service.GlobalCacheSVC BUKKIT_API org.bukkit.event.Listener art.arcane.iris.core.service.LogFilterSVC BUKKIT_API org.bukkit.event.Listener @@ -37,10 +39,13 @@ art.arcane.iris.core.service.ObjectStudioSaveService BUKKIT_API org.bukkit.event art.arcane.iris.core.service.PreservationSVC BUKKIT_API org.bukkit.event.Listener art.arcane.iris.core.service.StudioSVC BUKKIT_API org.bukkit.event.Listener art.arcane.iris.core.service.TreeSVC BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.tools.IrisCreator BUKKIT_API org.bukkit.command.CommandSender art.arcane.iris.core.tools.IrisToolbelt BUKKIT_API org.bukkit.command.CommandSender art.arcane.iris.core.tools.IrisWorldCreator BUKKIT_API org.bukkit.generator.ChunkGenerator -art.arcane.iris.core.tools.TreePlausibilizer BUKKIT_API org.bukkit.Keyed art.arcane.iris.engine.IrisWorldManager BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.engine.WorldChunkMaintenance BUKKIT_API org.bukkit.entity.Entity +art.arcane.iris.engine.WorldEntitySpawner BUKKIT_API org.bukkit.entity.Entity +art.arcane.iris.engine.WorldTeleportWarmup BUKKIT_API org.bukkit.command.CommandSender art.arcane.iris.engine.framework.EngineAssignedWorldManager BUKKIT_API org.bukkit.event.Listener art.arcane.iris.engine.framework.EnginePlayer BUKKIT_API org.bukkit.entity.Entity art.arcane.iris.engine.framework.placer.WorldObjectPlacer BUKKIT_API org.bukkit.event.Event @@ -60,6 +65,7 @@ art.arcane.iris.engine.platform.BukkitChunkGenerator$2 BUKKIT_API org.bukkit.Hei art.arcane.iris.engine.platform.DummyBiomeProvider BUKKIT_API org.bukkit.generator.BiomeProvider art.arcane.iris.engine.platform.DummyChunkGenerator BUKKIT_API org.bukkit.generator.ChunkGenerator art.arcane.iris.engine.platform.EngineBukkitOps BUKKIT_API org.bukkit.entity.Entity +art.arcane.iris.engine.platform.EngineBukkitOps$2 BUKKIT_API org.bukkit.block.BlockFace art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator BUKKIT_API org.bukkit.Material art.arcane.iris.platform.bukkit.BukkitBlockResolution BUKKIT_API org.bukkit.Material art.arcane.iris.platform.bukkit.BukkitBlockState BUKKIT_API org.bukkit.Keyed @@ -68,7 +74,6 @@ art.arcane.iris.platform.bukkit.BukkitWorldBinding BUKKIT_API org.bukkit.generat art.arcane.iris.util.common.data.IrisCustomData BUKKIT_API org.bukkit.block.data.BlockData art.arcane.iris.util.common.data.registry.Attributes BUKKIT_API org.bukkit.attribute.Attribute art.arcane.iris.util.common.data.registry.Materials BUKKIT_API org.bukkit.Material -art.arcane.iris.util.common.data.registry.Particles BUKKIT_API org.bukkit.Particle art.arcane.iris.util.common.director.handlers.VectorHandler BUKKIT_API org.bukkit.util.Vector art.arcane.iris.util.common.director.handlers.WorldHandler BUKKIT_API org.bukkit.generator.WorldInfo art.arcane.iris.util.common.format.C$DyeMaps BUKKIT_API org.bukkit.DyeColor diff --git a/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java b/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java index 9c611cd43..1e5ac0ccf 100644 --- a/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java +++ b/spi/src/main/java/art/arcane/iris/spi/PlatformRegistries.java @@ -41,6 +41,10 @@ public interface PlatformRegistries { /** * Resolves a block key, returning null instead of an air fallback when it does not resolve. Silent. + *

+ * Unlike {@link #block(String)} this never consults the platform's compatibility layer. On Bukkit that layer only + * sees keys the underlying lookup could not answer at all, so an unregistered key resolves to air through + * {@link #block(String)} on every platform - the Bukkit-only legacy rewrite table cannot fork generation output. */ PlatformBlockState blockOrNull(String key); @@ -109,6 +113,18 @@ public interface PlatformRegistries { */ List blockTypeKeys(); + /** + * Every entity key contributed by third-party content integrations rather than the vanilla entity registry - + * Bukkit item/mob plugins on the Bukkit side, registered custom-content providers on mod loaders. Feeds pack + * schema completion for custom mob types only; never the spawn path. Never null. + *

+ * Defaults to empty so a platform with no integration surface needs no implementation, and so schema + * generation never has to reference a platform-specific service type directly. + */ + default List specialEntityKeys() { + return List.of(); + } + /** * Every registered enchantment key. Never null. */ diff --git a/spi/src/main/java/art/arcane/iris/spi/protocol/IrisMessageCodec.java b/spi/src/main/java/art/arcane/iris/spi/protocol/IrisMessageCodec.java index c8f5d0938..85815bd73 100644 --- a/spi/src/main/java/art/arcane/iris/spi/protocol/IrisMessageCodec.java +++ b/spi/src/main/java/art/arcane/iris/spi/protocol/IrisMessageCodec.java @@ -38,7 +38,10 @@ public final class IrisMessageCodec { * Encodes {@code message} into a frame: type id as a varint, then the record's fields in declaration order. * Never returns null. * - * @throws IllegalStateException if the encoded form exceeds {@link IrisProtocol#MAX_FRAME_BYTES} + * @throws IllegalStateException if the encoded form exceeds {@link IrisProtocol#MAX_FRAME_BYTES}, or if a + * {@link IrisMessage.VisionMarkers} carries more than + * {@link IrisProtocol#MAX_VISION_MARKERS} entries (the decoder rejects such a + * frame, so producing one would only strand the payload) */ public static byte[] encode(IrisMessage message) { IrisWireWriter writer = new IrisWireWriter(); @@ -106,6 +109,9 @@ public final class IrisMessageCodec { writer.writeInt(visionMarkers.tileZ()); writer.writeInt(visionMarkers.zoomLevel()); List markers = visionMarkers.markers(); + if (markers.size() > IrisProtocol.MAX_VISION_MARKERS) { + throw new IllegalStateException("marker count " + markers.size() + " exceeds " + IrisProtocol.MAX_VISION_MARKERS); + } writer.writeVarInt(markers.size()); for (IrisMessage.VisionMarkers.Marker marker : markers) { writer.writeInt(marker.blockX()); @@ -178,7 +184,10 @@ public final class IrisMessageCodec { if (count < 0) { throw new ProtocolException("negative marker count"); } - List markers = new ArrayList<>(Math.min(count, IrisProtocol.MAX_VISION_MARKERS)); + if (count > IrisProtocol.MAX_VISION_MARKERS) { + throw new ProtocolException("marker count " + count + " exceeds " + IrisProtocol.MAX_VISION_MARKERS); + } + List markers = new ArrayList<>(count); for (int index = 0; index < count; index++) { markers.add(new IrisMessage.VisionMarkers.Marker(reader.readInt(), reader.readInt(), reader.readInt(), reader.readString())); } diff --git a/spi/src/main/java/art/arcane/iris/spi/protocol/IrisProtocol.java b/spi/src/main/java/art/arcane/iris/spi/protocol/IrisProtocol.java index 5d53beda7..597f74f15 100644 --- a/spi/src/main/java/art/arcane/iris/spi/protocol/IrisProtocol.java +++ b/spi/src/main/java/art/arcane/iris/spi/protocol/IrisProtocol.java @@ -39,6 +39,17 @@ public final class IrisProtocol { public static final int MAX_INBOUND_FRAMES_PER_SECOND = 32; /** Vision tile requests accepted per client per second, tighter than the general frame budget because each one costs a render. */ public static final int MAX_VISION_TILE_REQUESTS_PER_SECOND = 8; + /** + * Cursor lookups accepted per client per second. Its own budget rather than a slice of + * {@link #MAX_INBOUND_FRAMES_PER_SECOND}: each lookup drives three engine column queries, so a client that + * spent its whole frame budget on cursors would cost 32 column resolves per second per player. + */ + public static final int MAX_CURSOR_INFO_REQUESTS_PER_SECOND = 4; + /** + * Largest absolute block coordinate a client may ask the generator about - the vanilla world border limit. + * Coordinates outside it are rejected rather than clamped so a spoofed frame is counted, not served. + */ + public static final int MAX_QUERY_BLOCK_COORDINATE = 29_999_999; /** Fixed header size of a vision tile frame, subtracted when splitting a tile into chunks. */ public static final int VISION_TILE_HEADER_BYTES = 25; /** Largest payload carried by one vision tile chunk. */