This commit is contained in:
Brian Neumann-Fopiano
2026-08-01 22:39:09 -04:00
parent 324cf9e095
commit a8217b42e9
220 changed files with 10626 additions and 1661 deletions
+27 -4
View File
@@ -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 <name> [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.
+3 -3
View File
@@ -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
@@ -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.
*
* <p>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.
*
* <p>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> biome) {
FeatureTable table = featureTable;
if (table == null) {
return null;
}
Holder<Biome> 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.
*
* <p>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<Biome> registry = level.registryAccess().lookupOrThrow(Registries.BIOME);
Set<String> 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<Holder<Biome>> biomes = new ArrayList<>();
Map<String, Holder<Biome>> byKey = new HashMap<>();
registry.listElements().forEach((Holder.Reference<Biome> 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<String, Holder<Biome>> derivatives = customBiomeDerivatives(registry, byKey);
List<FeatureSorter.StepFeatureData> steps;
try {
steps = FeatureSorter.buildFeaturesPerStep(biomes,
(Holder<Biome> 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<String> visibleBiomeKeys() {
Set<String> 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<String, Holder<Biome>> customBiomeDerivatives(Registry<Biome> registry,
Map<String, Holder<Biome>> byKey) {
Map<String, Holder<Biome>> 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<Biome> 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<Biome> resolveHolder(Registry<Biome> 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).<Holder<Biome>>map((Holder.Reference<Biome> reference) -> reference)
.orElse(null);
}
private static BiomeGenerationSettings settingsFor(Holder<Biome> biome,
Map<String, Holder<Biome>> derivatives) {
Holder<Biome> 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<PlacedFeature> featureRegistry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE);
List<FeatureSorter.StepFeatureData> steps = table.steps();
WorldgenRandom random = new WorldgenRandom(new XoroshiroRandomSource(RandomSupport.generateUniqueSeed()));
long decorationSeed = random.setDecorationSeed(level.getSeed(), origin.getX(), origin.getZ());
Set<Holder<Biome>> 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<PlacedFeature> featureRegistry, Set<Holder<Biome>> chunkBiomes,
ChunkGenerator owner, WorldgenRandom random, long decorationSeed,
BlockPos origin, int stepIndex) {
IntSet stepFeatures = new IntArraySet();
for (Holder<Biome> biome : chunkBiomes) {
List<HolderSet<PlacedFeature>> biomeFeatures = settingsFor(biome, table.derivatives()).features();
if (stepIndex >= biomeFeatures.size()) {
continue;
}
for (Holder<PlacedFeature> 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<PlacedFeature> registry, PlacedFeature feature) {
Identifier id = registry.getKey(feature);
return id == null ? feature.toString() : id.toString();
}
private Set<Holder<Biome>> chunkBiomes(WorldGenLevel level, SectionPos sectionPos, FeatureTable table) {
List<Holder<Biome>> 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<Holder<Biome>> present = new LinkedHashSet<>();
for (Holder<Biome> biome : collected) {
if (table.biomeSet().contains(biome)) {
present.add(biome);
continue;
}
String key = holderKey(biome);
Holder<Biome> canonical = key == null ? null : table.byKey().get(key);
if (canonical != null) {
present.add(canonical);
}
}
return present;
}
private static void addKey(Set<String> keys, String key) {
String normalized = normalizeKey(key);
if (normalized != null) {
keys.add(normalized);
}
}
private String dimensionKey() {
IrisDimension dimension = engine.getDimension();
return dimension == null ? "<unbound>" : dimension.getLoadKey();
}
private static String holderKey(Holder<Biome> 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<Holder<Biome>> biomes, Set<Holder<Biome>> biomeSet,
Map<String, Holder<Biome>> byKey,
List<FeatureSorter.StepFeatureData> steps,
Map<String, Holder<Biome>> derivatives, boolean filtered) {
}
}
@@ -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<SpawnTableKey, WeightedList<MobSpawnSettings.SpawnerData>> 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<Biome> 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();
+9
View File
@@ -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')
}
@@ -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;
}
@@ -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
@@ -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);
}
@@ -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);
@@ -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<IrisTileKey, List<IrisMessage.VisionMarkers.Marker>> byTile;
static final int MAX_TILES = 64;
private final LinkedHashMap<IrisTileKey, List<IrisMessage.VisionMarkers.Marker>> byTile;
public IrisClientMarkers() {
this.byTile = new ConcurrentHashMap<>();
this.byTile = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<IrisTileKey, List<IrisMessage.VisionMarkers.Marker>> 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<IrisMessage.VisionMarkers.Marker> forTile(IrisTileKey key) {
public synchronized List<IrisMessage.VisionMarkers.Marker> forTile(IrisTileKey key) {
return byTile.get(key);
}
public void clear() {
public synchronized int trackedTiles() {
return byTile.size();
}
public synchronized void clear() {
byTile.clear();
}
}
@@ -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<Long, IrisMessage.PregenProgress> 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<Long, Entry> 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<Long, IrisClientPregenState.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) {
}
}
@@ -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;
@@ -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<IrisTileKey, Long> pending;
private final Deque<IrisTileKey> queue;
private final Set<IrisTileKey> 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<IrisTileKey, IrisTileImage> 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;
}
@@ -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) {
@@ -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<IrisTileKey, Partial> 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() || partial.chunkCount() != chunkCount) {
if (partial != null && tile.sequence() < partial.sequence()) {
return null;
}
if (partial == null || tile.sequence() > partial.sequence() || partial.chunkCount() != chunkCount) {
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;
@@ -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();
}
@@ -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() {
}
@@ -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<IrisTileKey> 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,8 +267,15 @@ public final class IrisVisionScreen extends Screen {
private Identifier ensureTexture(IrisTileKey key, IrisTileImage image) {
TileTexture existing = textures.get(key);
if (existing != null) {
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();
int[] argb = image.argb();
@@ -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<Map.Entry<IrisTileKey, TileTexture>> iterator = textures.entrySet().iterator();
while (textures.size() > MAX_TEXTURES && iterator.hasNext()) {
while (textures.size() > capacity && iterator.hasNext()) {
Map.Entry<IrisTileKey, TileTexture> 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) {
}
}
@@ -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;
@@ -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;
}
@@ -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<Component> info) {
ModdedMixinFlags.markWorldTypeEntry();
Optional<ResourceKey<WorldPreset>> key = preset == null
? Optional.empty()
: preset.unwrapKey();
+76 -1
View File
@@ -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<Object> 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<String, String> 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,13 +252,61 @@ 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 {
+96 -28
View File
@@ -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.<init>(JSONObject.java:145)
at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:345)
at art.arcane.volmlib.util.json.JSONArray.<init>(JSONArray.java:111)
at art.arcane.volmlib.util.json.JSONTokener.nextValue(JSONTokener.java:348)
at art.arcane.volmlib.util.json.JSONObject.<init>(JSONObject.java:159)
at art.arcane.volmlib.util.json.JSONObject.<init>(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
@@ -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
@@ -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();
});
}
@@ -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": [
@@ -2,7 +2,7 @@
"required": true,
"minVersion": "0.8",
"package": "art.arcane.iris.fabric.mixin",
"compatibilityLevel": "JAVA_25",
"compatibilityLevel": "JAVA_21",
"mixins": [
"BlockItemMixin",
"BlockMixin",
+34 -3
View File
@@ -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 {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+94 -30
View File
@@ -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.<init>(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.<init>(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.<init>(JSONObject.java:159) ~[shared-local-SNAPSHOT.jar:?]
at art.arcane.volmlib.util.json.JSONObject.<init>(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)
+91 -27
View File
@@ -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.<init>(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.<init>(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.<init>(JSONObject.java:159) ~[shared-local-SNAPSHOT.jar:?]
at art.arcane.volmlib.util.json.JSONObject.<init>(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)
@@ -43,7 +43,7 @@ import java.nio.file.Path;
public final class ForgeModdedLoader implements ModdedLoader {
public static final PermissionNode<Boolean> TREE_FELLER_PERMISSION = new PermissionNode<>(
"iris",
"irisworldgen",
"treefeller",
PermissionTypes.BOOLEAN,
(player, playerId, contexts) ->
@@ -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
@@ -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();
}
@@ -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) {
@@ -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"
@@ -1,10 +0,0 @@
{
"pack": {
"description": "Iris World Generation Engine resources",
"max_format": 101,
"min_format": [
101,
1
]
}
}
@@ -33,6 +33,7 @@ final class InitialSpawnQueue {
private final long maxAgeNanos;
private final LongSupplier nanoTime;
private final ArrayDeque<Long> queue;
private final ArrayDeque<Expiry> expiryOrder;
private final Map<Long, Long> pending;
private final Set<Long> 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);
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.
*
* <p>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<Long> expired = new HashSet<>();
for (Map.Entry<Long, Long> 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) {
}
}
@@ -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<String> warnedUnresolvedBiomeKeys = ConcurrentHashMap.newKeySet();
private final ConcurrentHashMap<Long, Holder<Biome>> visibleBiomeCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, Holder<Biome>> structureBiomeCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, Holder<Biome>> surfaceStructureBiomeCache = new ConcurrentHashMap<>();
private final Set<StructureStateBiomeSource> 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<String> 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<StructureSet> structureSets) {
LinkedHashSet<Holder<Biome>> possible = new LinkedHashSet<>();
Registry<Biome> registry = biomeRegistry();
@@ -113,12 +136,68 @@ final class IrisModdedBiomeSource extends BiomeSource {
@Override
protected Stream<Holder<Biome>> collectPossibleBiomes() {
Set<String> 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<Holder<Biome>> possibleBiomes() {
return resolvePossibleBiomes().set();
}
/**
* Registry-ordered view of {@link #possibleBiomes()} for platform code that has to build a feature table.
*/
List<Holder<Biome>> 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<Biome> registeredBiome(String key) {
Registry<Biome> 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<Holder<Biome>> ordered = collectPossibleBiomeHolders();
PossibleBiomes resolved = new PossibleBiomes(generation, ordered,
Collections.unmodifiableSet(new LinkedHashSet<>(ordered)));
possibleBiomesCache = resolved;
return resolved;
}
private List<Holder<Biome>> collectPossibleBiomeHolders() {
BiomeKeySets keys = biomeKeySets();
Set<String> generatedBiomeKeys = requireConfiguredStructureBiomeKeys(keys.required());
Set<String> visibleBiomeKeys = keys.visibleOnly();
Registry<Biome> registry = biomeRegistry();
LinkedHashSet<Holder<Biome>> possible = new LinkedHashSet<>();
if (registry == null) {
for (Holder<Biome> 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<Biome> 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<Biome> registry, Set<String> visibleBiomeKeys) {
if (visibleBiomeKeys.isEmpty()) {
return;
}
Set<String> 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<Biome> cached = structureBiomeCache.get(key);
BiomeHolderTable cache = structureBiomeCache;
Holder<Biome> cached = cache.get(key);
if (cached != null) {
return cached;
}
Holder<Biome> resolved = resolveStructureBiome(engine, quartX, quartY, quartZ, sampler);
Holder<Biome> 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<Biome> 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<Biome> cached = visibleBiomeCache.get(key);
BiomeHolderTable cache = visibleBiomeCache;
Holder<Biome> cached = cache.get(key);
if (cached != null) {
return cached;
}
Holder<Biome> resolved = resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler);
Holder<Biome> 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<Biome> getSurfaceStructureBiome(Engine engine, int quartX, int quartZ,
Climate.Sampler sampler) {
long key = packColumnKey(quartX, quartZ);
Holder<Biome> cached = surfaceStructureBiomeCache.get(key);
BiomeHolderTable cache = surfaceStructureBiomeCache;
Holder<Biome> cached = cache.get(key);
if (cached != null) {
return cached;
}
Holder<Biome> resolved = resolveSurfaceStructureBiome(engine, quartX, quartZ, sampler);
Holder<Biome> 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<Biome> resolveSurfaceStructureBiome(Engine engine, int quartX, int quartZ,
@@ -500,13 +599,35 @@ final class IrisModdedBiomeSource extends BiomeSource {
}
private Set<String> 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<String> possible = new LinkedHashSet<>();
LinkedHashSet<String> required = new LinkedHashSet<>();
LinkedHashSet<String> visible = new LinkedHashSet<>();
for (IrisBiome irisBiome : engine.getAllBiomes()) {
String derivative = normalizeKey(irisBiome.getStructureDerivativeKey());
if (derivative != null) {
possible.add(derivative);
}
if (!irisBiome.isCustom()) {
continue;
required.add(derivative);
}
if (irisBiome.isCustom()) {
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
possible.add(ModdedWorldgenIds.biomeRef(engine, customBiome.getId()));
required.add(ModdedWorldgenIds.biomeRef(engine, customBiome.getId()));
}
}
return Set.copyOf(possible);
addVisibleBiomeKey(visible, irisBiome.getDerivativeKey());
for (String scatter : irisBiome.getBiomeScatter()) {
addVisibleBiomeKey(visible, scatter);
}
for (String scatter : irisBiome.getBiomeSkyScatter()) {
addVisibleBiomeKey(visible, scatter);
}
}
visible.removeAll(required);
return new BiomeKeySets(generation, Set.copyOf(required), Set.copyOf(visible));
}
}
private static void addVisibleBiomeKey(Set<String> 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<String> required, Set<String> visibleOnly) {
}
private record PossibleBiomes(long generation, List<Holder<Biome>> ordered, Set<Holder<Biome>> 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.
*
* <p>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<Entry> table;
Holder<Biome> get(long key) {
AtomicReferenceArray<Entry> 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<Biome> value) {
AtomicReferenceArray<Entry> 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<Entry> install() {
AtomicReferenceArray<Entry> existing = table;
if (existing != null) {
return existing;
}
AtomicReferenceArray<Entry> 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<Biome> value) {
}
}
private static final class StructureStateBiomeSource extends BiomeSource {
private final IrisModdedBiomeSource delegate;
private final Set<Holder<Biome>> possibleBiomes;
private final ConcurrentHashMap<Long, Holder<Biome>> resolvedBiomes = new ConcurrentHashMap<>();
private volatile BiomeHolderTable resolvedBiomes = new BiomeHolderTable();
private StructureStateBiomeSource(IrisModdedBiomeSource delegate, Set<Holder<Biome>> 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<Biome> getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) {
long key = packNoiseKey(x, y, z);
Holder<Biome> cached = resolvedBiomes.get(key);
BiomeHolderTable cache = resolvedBiomes;
Holder<Biome> cached = cache.get(key);
if (cached != null) {
return cached;
}
Holder<Biome> resolved = delegate.resolveRequiredStructureBiome(x, y, z);
Holder<Biome> 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
@@ -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<IrisModdedChunkGenerator> CODEC = RecordCodecBuilder.mapCodec((RecordCodecBuilder.Instance<IrisModdedChunkGenerator> 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<Engine> 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<String> 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();
}
@@ -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<String> 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<String> 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<String> 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<String> 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<String> processArguments() {
try {
Optional<String[]> 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<Path> moved = new ArrayList<>();
@@ -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<MinecraftServer> server;
private final AtomicBoolean serverMissingReported = new AtomicBoolean();
private volatile RegistryCache cache;
public ModdedBiomeWriter(Supplier<MinecraftServer> server) {
this.server = server;
@@ -50,9 +57,33 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter {
public int biomeIdFor(String key) {
Registry<Biome> 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<Biome> 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<PlatformBiome> allBiomes() {
Registry<Biome> registry = biomeRegistry();
List<PlatformBiome> biomes = new ArrayList<>();
if (registry == null) {
return biomes;
reportMissingServer("enumerate the biome registry", "returning no biomes");
return new ArrayList<>();
}
RegistryCache cached = cacheFor(registry);
List<PlatformBiome> snapshot = cached.biomes;
if (snapshot == null) {
List<PlatformBiome> built = new ArrayList<>();
for (Identifier identifier : registry.keySet()) {
Biome biome = registry.getValue(identifier);
if (biome != null) {
biomes.add(ModdedBiome.of(biome, identifier.toString()));
built.add(ModdedBiome.of(biome, identifier.toString()));
}
}
return biomes;
snapshot = List.copyOf(built);
cached.biomes = snapshot;
}
return new ArrayList<>(snapshot);
}
private int idForKey(Registry<Biome> 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<Biome> 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<Biome> registry;
private final ConcurrentHashMap<String, Integer> ids = new ConcurrentHashMap<>();
private volatile List<PlatformBiome> biomes;
private RegistryCache(Registry<Biome> registry) {
this.registry = registry;
}
}
}
@@ -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,11 +83,8 @@ 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);
}
}
public static void clear() {
PENDING.clear();
@@ -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;
@@ -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<String, Handle> 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<Level> 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) {
@@ -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<PersistentDimension> loadForStartup(MinecraftServer server) {
return loadForStartup(storeFile(server));
}
static List<PersistentDimension> 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<String> 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<String> salvageIds(Path file) {
List<String> 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<>());
@@ -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;
@@ -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<Pack> 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).
*
* <p>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<String> entries = new ArrayList<>();
Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
@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<String, KSet<String>> 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<Path> entries = new ArrayList<>();
try (Stream<Path> 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:<dimensionTypeKey> 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) {
}
}
@@ -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.
*
* <p>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<ExecutorService> 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) {
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 <configClass>.<section>.<flag>} 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"
};
for (String marker : markers) {
private static Method declaredMethodOrNull(Class<?> type, String name) {
try {
Class.forName(marker, false, IrisModdedChunkGenerator.class.getClassLoader());
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) {
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) {
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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}.
*
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>With {@code importedFeatures} disabled there is no table and this is vanilla's default getter.
*/
BiomeGenerationSettings generationSettings(Holder<Biome> 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<Holder<Biome>> biomes = biomeSource.orderedPossibleBiomes();
if (biomes.isEmpty()) {
LOGGER.error("Iris importedFeatures is on but {} exposes no biomes; features off",
dimensionKey(engine));
return null;
}
Map<String, Holder<Biome>> byKey = new HashMap<>(biomes.size());
for (Holder<Biome> biome : biomes) {
String key = holderKey(biome);
if (key != null) {
byKey.put(key, biome);
}
}
Map<String, Holder<Biome>> derivatives = customBiomeDerivatives(engine, byKey);
List<FeatureSorter.StepFeatureData> 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> 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<String, Holder<Biome>> customBiomeDerivatives(Engine engine, Map<String, Holder<Biome>> byKey) {
Map<String, Holder<Biome>> 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<Biome> 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> biome,
Map<String, Holder<Biome>> derivatives) {
Holder<Biome> 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<PlacedFeature> featureRegistry = level.registryAccess().lookupOrThrow(Registries.PLACED_FEATURE);
List<FeatureSorter.StepFeatureData> steps = table.steps();
WorldgenRandom random = new WorldgenRandom(new XoroshiroRandomSource(RandomSupport.generateUniqueSeed()));
long decorationSeed = random.setDecorationSeed(level.getSeed(), origin.getX(), origin.getZ());
Set<Holder<Biome>> 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<PlacedFeature> featureRegistry, Set<Holder<Biome>> chunkBiomes,
IrisModdedChunkGenerator owner, WorldgenRandom random, long decorationSeed,
BlockPos origin, int stepIndex) {
IntSet stepFeatures = new IntArraySet();
for (Holder<Biome> biome : chunkBiomes) {
List<HolderSet<PlacedFeature>> biomeFeatures = settingsFor(biome, table.derivatives()).features();
if (stepIndex >= biomeFeatures.size()) {
continue;
}
for (Holder<PlacedFeature> 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<PlacedFeature> 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<Holder<Biome>> chunkBiomes(WorldGenLevel level, SectionPos sectionPos, FeatureTable table) {
List<Holder<Biome>> 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<Holder<Biome>> present = new LinkedHashSet<>();
for (Holder<Biome> biome : collected) {
if (table.biomeSet().contains(biome)) {
present.add(biome);
continue;
}
String key = holderKey(biome);
Holder<Biome> 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 ? "<unbound>" : engine.getDimension().getLoadKey();
}
private static String holderKey(Holder<Biome> 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<Holder<Biome>> biomes, Set<Holder<Biome>> biomeSet,
Map<String, Holder<Biome>> byKey,
List<FeatureSorter.StepFeatureData> steps,
Map<String, Holder<Biome>> derivatives, boolean filtered) {
}
}
@@ -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<String> 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.Reference<Enchantment>> 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;
}
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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.
*
* <p>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$<ids>$<originalName>}, 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<ExpectedMixin> 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<String> missing = new ArrayList<>();
List<String> 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$<ids>$<originalName>, 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) {
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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.
*
* <p>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;
}
}
@@ -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());
@@ -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;
}
return PackDownloader.download(
boolean installed = PackDownloader.isDefaultOverworld(pack)
? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback) != null
: PackDownloader.download(
packs,
"IrisDimensions/" + pack,
branch,
forceOverwrite,
false,
feedback
) != null;
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 installed;
} catch (IOException error) {
LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error);
feedback.accept(IrisLanguage.plain(
@@ -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<String, ErrorThrottle> ERROR_THROTTLES = new ConcurrentHashMap<>();
private static volatile Consumer<Throwable> ERROR_SINK = null;
private static volatile Consumer<Throwable> 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<Throwable> 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<String, ErrorThrottle> 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
? "<no-frame>"
: 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;
@@ -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;
@@ -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<String, Engine> SESSION_ENGINES = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, String> SESSION_LEVELS = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<UUID, String> 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;
@@ -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<MinecraftServer> server;
public ModdedRegistries(Supplier<MinecraftServer> server) {
@@ -126,6 +132,7 @@ public final class ModdedRegistries implements PlatformRegistries {
List<String> keys = new ArrayList<>();
Registry<Biome> 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<String> 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<String> specialEntityKeys() {
return ModdedCustomContentRegistry.providerKeys(ModdedDataType.ENTITY);
}
@Override
public List<String> entityKeys() {
List<String> 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<String> keys = new ArrayList<>();
Registry<Enchantment> 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<String, List<PlatformBlockProperty>> blockStateProperties() {
Map<String, List<PlatformBlockProperty>> 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<String, List<PlatformBlockProperty>> shared = new LinkedHashMap<>();
for (Block block : BuiltInRegistries.BLOCK) {
BlockState defaultState = block.defaultBlockState();
List<PlatformBlockProperty> 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<PlatformBlockProperty> group = shared.computeIfAbsent(groupSignature(converted), key -> List.copyOf(converted));
properties.put(BuiltInRegistries.BLOCK.getKey(block).toString(), group);
}
List<PlatformBlockProperty> none = shared.computeIfAbsent(groupSignature(List.of()), key -> List.of());
for (String key : customBlockKeys()) {
properties.putIfAbsent(key, none);
}
return properties;
}
private static String groupSignature(List<PlatformBlockProperty> 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<String> customBlockKeys() {
List<String> 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<Biome> biomeRegistry() {
MinecraftServer instance = server.get();
if (instance == null) {
@@ -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.";
}
}
@@ -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<Runnable> mainQueue = new ConcurrentLinkedQueue<>();
private final ConcurrentLinkedQueue<DelayedTask> delayedQueue = new ConcurrentLinkedQueue<>();
private final PriorityBlockingQueue<DelayedTask> 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<Runnable> 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<Runnable> 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,31 +166,55 @@ 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()) {
/**
* 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;
}
List<DelayedTask> retained = new ArrayList<>();
DelayedTask delayed;
while ((delayed = delayedQueue.poll()) != null) {
if (delayed.tick()) {
mainQueue.add(delayed.task());
} else {
retained.add(delayed);
}
}
delayedQueue.addAll(retained);
}
private boolean onMainThread() {
Thread main = mainThread;
@@ -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<DelayedTask> {
@Override
public int compareTo(DelayedTask other) {
int byTick = Long.compare(dueTick, other.dueTick);
return byTick != 0 ? byTick : Long.compare(sequence, other.sequence);
}
}
@@ -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<MinecraftServer> levelCacheInvalidator;
public ModdedServerLevels(Consumer<MinecraftServer> 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<ServerLevel> 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<Level> 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<ServerLevel> 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<ResourceKey<Level>, 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<Level> 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<Level> 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<Level> 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<Level> key) {
return server.levels.containsKey(key);
}
private record Snapshot(MinecraftServer server, List<ServerLevel> levels,
Map<ResourceKey<Level>, ServerLevel> byKey) {
}
}
@@ -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) {
@@ -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<ModdedDimensionRegistryStore.PersistentDimension> dimensions = ModdedDimensionRegistryStore.load(server);
List<ModdedDimensionRegistryStore.PersistentDimension> 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<Path> walk = Files.walk(root)) {
for (Path path : (Iterable<Path>) 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<String, PackValidationResult> 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() {
@@ -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<MinecraftServer> server;
public ModdedStructureHooks(Supplier<MinecraftServer> 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<Structure> 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()};
}
@@ -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<String, Object> tileProperties;
private final String expectedBlockKey;
private final int legacyType;
private int hash;
ModdedTileData(byte[] raw, KMap<String, Object> 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<String, Object> properties = new KMap<>();
properties.put(NBT_PROPERTY, snbt);
KMap<String, Object> 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.
* <p>
* 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.
* <p>
* 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<String, Object> captureProperties(String blockKey, String snbt) {
KMap<String, Object> 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<String, Object>) map);
}
} catch (Throwable e) {
if (SNBT_FALLBACK.firstOccurrence(blockKey == null ? "<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<String, Object> 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<String, Object> 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<Object> 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 <T extends Comparable<T>> BlockState copyProperty(BlockState target, BlockState source, Property<T> property) {
return target.setValue(property, source.getValue(property));
}
private static Object deepCopy(Object value) {
if (value instanceof Map<?, ?> map) {
KMap<String, Object> 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<Object> 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.
* <p>
* 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<String, Object>) deepCopy(tileProperties), expectedBlockKey, legacyType);
}
}
@@ -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;
}
@@ -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<Long> 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<MobCategory> 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() {
@@ -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 <https://www.gnu.org/licenses/>.
*/
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.
*
* <p>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.
*
* <p>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<String> 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;
}
}
}
@@ -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<String> 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<String> providerKeys(ModdedDataType type) {
if (type == null || PROVIDERS.isEmpty()) {
return List.of();
}
List<String> keys = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (ModdedDataProvider provider : PROVIDERS) {
Collection<Identifier> 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
@@ -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<String> 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++;
}
@@ -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<String> consoleLines(String section) {
List<Entry> entries = SECTIONS.get(section);
if (entries == null) {
return List.of();
}
List<String> 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) {
@@ -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<Suggestions> suggestDimensionNames(CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) {
ModdedCommandFeedback.tab(context.getSource());
List<String> 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());
}
@@ -36,25 +36,42 @@ import java.util.function.Predicate;
final class ModdedCommandTree {
private static final Predicate<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> rootTree() {
LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal("iris");
root.executes((CommandContext<CommandSourceStack> context) -> ModdedCommandHelp.send(context.getSource(), ""));
root.then(helpTree());
root.then(Commands.literal("version")
root.then(Commands.literal("version").requires(READ_ONLY)
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.version(context.getSource())));
root.then(Commands.literal("info").requires(GATE)
root.then(Commands.literal("info").requires(READ_ONLY)
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), null))
.then(Commands.argument("dimension", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.DIMENSION_NAMES)
.executes((CommandContext<CommandSourceStack> 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<CommandSourceStack> context) -> IrisModdedCommands.reload(context.getSource())));
root.then(Commands.literal("height").requires(GATE)
root.then(Commands.literal("height").requires(READ_ONLY)
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.height(context.getSource())));
root.then(Commands.literal("worlds").requires(GATE)
root.then(Commands.literal("worlds").requires(READ_ONLY)
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), null)));
root.then(Commands.literal("accesslist").requires(GATE)
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), null)));
@@ -162,7 +179,7 @@ final class ModdedCommandTree {
}
private static LiteralArgumentBuilder<CommandSourceStack> helpTree() {
return Commands.literal("help")
return Commands.literal("help").requires(READ_ONLY)
.executes((CommandContext<CommandSourceStack> context) -> ModdedCommandHelp.send(context.getSource(), ""))
.then(Commands.argument("section", StringArgumentType.greedyString())
.executes((CommandContext<CommandSourceStack> context) -> ModdedCommandHelp.send(context.getSource(), StringArgumentType.getString(context, "section"))));
@@ -202,7 +219,7 @@ final class ModdedCommandTree {
}
private static LiteralArgumentBuilder<CommandSourceStack> metricsTree(String name) {
return Commands.literal(name).requires(GATE)
return Commands.literal(name).requires(READ_ONLY)
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.metrics(context.getSource()));
}
@@ -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<String> written = new ArrayList<>();
for (ServerLevel level : server.getAllLevels()) {
for (ServerLevel level : ModdedServerLevels.levels(server)) {
if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator irisGenerator)) {
continue;
}
@@ -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,24 +119,46 @@ 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) {
if (engine == null) {
return false;
}
Mantle<Matter> 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
public boolean isSolid(int x, int y, int z) {
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
@@ -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<FinalSaveRequest> 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,20 +103,22 @@ 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() {
try {
if (!sync) {
try {
semaphore.tryAcquire(maxInFlight, 5, TimeUnit.SECONDS);
@@ -126,6 +132,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
return;
}
saveLevel(true);
} finally {
pauseGuard.restore();
}
}
@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,15 +478,13 @@ 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());
Executor exec = level.getServer().executor;
if (exec == null) {
return "unknown";
}
@@ -474,37 +495,154 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
return "ForkJoinPool(parallelism=" + fjp.getParallelism() + ")";
}
return exec.getClass().getSimpleName();
} catch (Throwable e) {
return "unknown";
}
}
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.
*
* <p>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<Thread> 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<Void> completion = new CompletableFuture<>();
private final AtomicBoolean active = new AtomicBoolean(true);
@@ -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<int[]> 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<PlatformBiome> 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();
}
@@ -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<String> loadedIrisDimensions(MinecraftServer server) {
List<String> 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());
}
@@ -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<Boolean> info) {
ModdedMixinFlags.markEntityPersistence();
Entity entity = (Entity) (Object) this;
info.setReturnValue(ModdedEntityPersistence.shouldSave(entity, info.getReturnValue()));
}
@@ -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();
}
@@ -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;
@@ -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;
}
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -2,7 +2,7 @@
"required": true,
"minVersion": "0.8",
"package": "art.arcane.iris.client.mixin",
"compatibilityLevel": "JAVA_25",
"compatibilityLevel": "JAVA_21",
"client": [
"IrisWorldOpenFlowsMixin",
"IrisWorldTypeEntryMixin"
@@ -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());
}
}
@@ -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);
}
}
@@ -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<IrisMessage.VisionTile> 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<IrisMessage.VisionTile> 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();
}
}
@@ -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<IrisMessage.VisionTile> 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<IrisMessage.VisionTile> 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<IrisMessage.VisionTile> 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<IrisMessage.VisionTile> 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<IrisMessage.VisionTile> 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<IrisMessage.VisionTile> overTheWire(List<IrisMessage.VisionTile> chunks) {
List<IrisMessage.VisionTile> 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;
}
}
@@ -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();
@@ -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);
}
}
@@ -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<PlatformBiome> first = writer.allBiomes();
List<PlatformBiome> second = writer.allBiomes();
assertTrue(first.isEmpty());
assertNotSame("callers must never share the writer cache instance", first, second);
first.add(null);
assertTrue(second.isEmpty());
}
}
@@ -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());
}
}
@@ -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.
*
* <p>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.
*
* <p>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<String> 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<String> violations = new ArrayList<>();
int scanned = 0;
for (String guarded : GUARDED_PACKAGES) {
Path directory = root.resolve(guarded);
if (!Files.isDirectory(directory)) {
continue;
}
try (Stream<Path> 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;
}
}
@@ -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<Path> 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<ModdedDimensionRegistryStore.PersistentDimension> 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<Path> entries;
try (Stream<Path> walk = Files.walk(root)) {
entries = walk.sorted(Comparator.reverseOrder()).toList();
}
for (Path entry : entries) {
Files.deleteIfExists(entry);
}
}
}

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