mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
F
This commit is contained in:
@@ -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
|
||||
|
||||
+393
@@ -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) {
|
||||
}
|
||||
}
|
||||
+22
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+10
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+67
-15
@@ -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;
|
||||
|
||||
+33
-3
@@ -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()) {
|
||||
return null;
|
||||
}
|
||||
if (partial == null || tile.sequence() > partial.sequence() || partial.chunkCount() != chunkCount) {
|
||||
if (partial != null && tile.sequence() < partial.sequence()) {
|
||||
return null;
|
||||
}
|
||||
partial = new Partial(tile.sequence(), chunkCount);
|
||||
partials.put(key, partial);
|
||||
} else if (tile.sequence() < partial.sequence()) {
|
||||
return null;
|
||||
}
|
||||
if (!partial.accept(chunkIndex, tile.data())) {
|
||||
return null;
|
||||
|
||||
@@ -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,7 +267,14 @@ public final class IrisVisionScreen extends Screen {
|
||||
private Identifier ensureTexture(IrisTileKey key, IrisTileImage image) {
|
||||
TileTexture existing = textures.get(key);
|
||||
if (existing != null) {
|
||||
return existing.id();
|
||||
if (existing.image() == image) {
|
||||
return existing.id();
|
||||
}
|
||||
// IrisClientTileCache replaced the tile with a freshly decoded image (new sequence from the
|
||||
// server). Identity is the generation counter: a re-decode is always a new record instance, so an
|
||||
// upload keyed only on the tile coordinates would show the stale render forever.
|
||||
Minecraft.getInstance().getTextureManager().release(existing.id());
|
||||
textures.remove(key);
|
||||
}
|
||||
int tileWidth = image.width();
|
||||
int tileHeight = image.height();
|
||||
@@ -261,17 +289,26 @@ public final class IrisVisionScreen extends Screen {
|
||||
DynamicTexture texture = new DynamicTexture(() -> "iris_vision_tile", nativeImage);
|
||||
Identifier id = Identifier.fromNamespaceAndPath("irisworldgen", texturePath(key));
|
||||
Minecraft.getInstance().getTextureManager().register(id, texture);
|
||||
textures.put(key, new TileTexture(id, texture));
|
||||
textures.put(key, new TileTexture(id, image));
|
||||
return id;
|
||||
}
|
||||
|
||||
private void evictTextures() {
|
||||
if (textures.size() <= MAX_TEXTURES) {
|
||||
/**
|
||||
* Texture budget for the current viewport: everything on screen plus a slack ring, clamped to sane bounds.
|
||||
* A fixed 220 is under half of what 4K at scale 1 draws, so the eviction pass used to fight the blit loop.
|
||||
*/
|
||||
private static int textureCapacity(int visibleTiles) {
|
||||
long required = (long) visibleTiles * (1 + 2 * TEXTURE_SLACK_RINGS);
|
||||
return (int) Math.max(MIN_TEXTURES, Math.min(MAX_TEXTURES, required));
|
||||
}
|
||||
|
||||
private void evictTextures(int capacity) {
|
||||
if (textures.size() <= capacity) {
|
||||
return;
|
||||
}
|
||||
TextureManager manager = Minecraft.getInstance().getTextureManager();
|
||||
Iterator<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;
|
||||
|
||||
+37
-1
@@ -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;
|
||||
}
|
||||
|
||||
+6
@@ -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();
|
||||
|
||||
@@ -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,15 +252,63 @@ tasks.named('shadowJar', ShadowJar).configure {
|
||||
exclude('META-INF/*.SF')
|
||||
exclude('META-INF/*.DSA')
|
||||
exclude('META-INF/*.RSA')
|
||||
// The only multi-release entry any bundled dependency contributes is an OSGi manifest. Keeping
|
||||
// it forces `Multi-Release: true` onto the mod jar, which makes the loaders treat the whole
|
||||
// archive as versioned and re-scan it per release directory for nothing.
|
||||
exclude('META-INF/versions/**')
|
||||
addMultiReleaseAttribute.set(false)
|
||||
// Invalid package name on Java 9+ ('enum' is a keyword); a module-path scan chokes on it.
|
||||
exclude('org/apache/commons/lang/enum/**')
|
||||
// Compile-only annotation carriers. Shipping them duplicates packages other mods also ship.
|
||||
exclude('org/jspecify/**')
|
||||
exclude('com/google/errorprone/**')
|
||||
exclude('com/google/j2objc/**')
|
||||
exclude('org/checkerframework/**')
|
||||
exclude('org/jetbrains/annotations/**')
|
||||
exclude('org/intellij/lang/**')
|
||||
// The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod
|
||||
// loader has. Shipping it only adds dead classes that reference a missing package.
|
||||
exclude('art/arcane/iris/platform/bukkit/**')
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm')
|
||||
relocate('io.sentry', 'art.arcane.iris.shadow.sentry')
|
||||
// Split-package guard. A co-installed mod that ships any of these at their original coordinates
|
||||
// puts two copies of the same package on one module layer, which the loaders reject at boot
|
||||
// (Terra ships paralithic; caffeine and dom4j are common in mod dependency trees). First-party
|
||||
// art.arcane.volmlib is deliberately NOT relocated.
|
||||
relocate('com.github.benmanes.caffeine', 'art.arcane.iris.shadow.caffeine')
|
||||
relocate('com.googlecode.concurrentlinkedhashmap', 'art.arcane.iris.shadow.clhm')
|
||||
relocate('com.dfsek.paralithic', 'art.arcane.iris.shadow.paralithic')
|
||||
relocate('org.dom4j', 'art.arcane.iris.shadow.dom4j')
|
||||
relocate('org.jaxen', 'art.arcane.iris.shadow.jaxen')
|
||||
relocate('org.zeroturnaround.zip', 'art.arcane.iris.shadow.ztzip')
|
||||
from(project.configurations.named('jij')) {
|
||||
into('META-INF/jars')
|
||||
rename { String fileName -> fileName.replaceAll(/-[0-9][^-]*\.jar$/, '.jar') }
|
||||
rename { String fileName ->
|
||||
String module = nestedFabricApiJars.keySet()
|
||||
.sort { String key -> -key.length() }
|
||||
.find { String key -> fileName.startsWith(key + '-') }
|
||||
if (module == null) {
|
||||
throw new GradleException("Undeclared Fabric API nested jar: ${fileName}. Add it to "
|
||||
+ 'nestedFabricApiJars and to the jars[] array in fabric.mod.json.')
|
||||
}
|
||||
|
||||
return nestedFabricApiJars.get(module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The thin `jar`/`remapJar` outputs carry valid fabric.mod.json metadata but bundle zero
|
||||
// dependencies. Installing one is an instant NoClassDefFoundError, and they sit in build/libs right
|
||||
// next to the real artifact. shadowJar is the only shippable Fabric jar.
|
||||
tasks.named('jar').configure {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
tasks.matching { it.name == 'remapJar' }.configureEach {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
tasks.named('assemble').configure {
|
||||
dependsOn(tasks.named('shadowJar'))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
@@ -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)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
+40
-14
@@ -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);
|
||||
queue.addLast(key);
|
||||
long offeredAt = nanoTime.getAsLong();
|
||||
pending.put(key, offeredAt);
|
||||
expiryOrder.addLast(new Expiry(key, offeredAt));
|
||||
if (queued.add(key)) {
|
||||
queue.addLast(key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -92,8 +97,10 @@ final class InitialSpawnQueue {
|
||||
pending.remove(key);
|
||||
continue;
|
||||
}
|
||||
expire(now);
|
||||
return key;
|
||||
}
|
||||
expire(now);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -115,6 +122,7 @@ final class InitialSpawnQueue {
|
||||
if (queued.remove(key)) {
|
||||
queue.removeFirstOccurrence(key);
|
||||
}
|
||||
expire(nanoTime.getAsLong());
|
||||
}
|
||||
|
||||
synchronized boolean isEmpty() {
|
||||
@@ -127,6 +135,7 @@ final class InitialSpawnQueue {
|
||||
|
||||
synchronized void clear() {
|
||||
queue.clear();
|
||||
expiryOrder.clear();
|
||||
pending.clear();
|
||||
queued.clear();
|
||||
}
|
||||
@@ -136,24 +145,41 @@ final class InitialSpawnQueue {
|
||||
clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops offers that aged out, plus entries for keys that already left {@code pending}.
|
||||
* {@code expiryOrder} is append-only and {@code nanoTime} is monotonic, so the deque is sorted by
|
||||
* offer time: the loop stops at the first live, unexpired entry. That makes the saturated-queue
|
||||
* call O(1) in the common case instead of the O(capacity) scan it used to be under the monitor,
|
||||
* and running it from poll/complete keeps the deque from growing past the live offers.
|
||||
*
|
||||
* <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) {
|
||||
}
|
||||
}
|
||||
|
||||
+260
-46
@@ -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);
|
||||
required.add(derivative);
|
||||
}
|
||||
if (!irisBiome.isCustom()) {
|
||||
continue;
|
||||
if (irisBiome.isCustom()) {
|
||||
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
|
||||
required.add(ModdedWorldgenIds.biomeRef(engine, customBiome.getId()));
|
||||
}
|
||||
}
|
||||
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
|
||||
possible.add(ModdedWorldgenIds.biomeRef(engine, customBiome.getId()));
|
||||
addVisibleBiomeKey(visible, irisBiome.getDerivativeKey());
|
||||
for (String scatter : irisBiome.getBiomeScatter()) {
|
||||
addVisibleBiomeKey(visible, scatter);
|
||||
}
|
||||
for (String scatter : irisBiome.getBiomeSkyScatter()) {
|
||||
addVisibleBiomeKey(visible, scatter);
|
||||
}
|
||||
}
|
||||
return Set.copyOf(possible);
|
||||
visible.removeAll(required);
|
||||
return new BiomeKeySets(generation, Set.copyOf(required), Set.copyOf(visible));
|
||||
}
|
||||
}
|
||||
|
||||
private static void addVisibleBiomeKey(Set<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
|
||||
|
||||
+124
-8
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+156
-15
@@ -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<>();
|
||||
}
|
||||
for (Identifier identifier : registry.keySet()) {
|
||||
Biome biome = registry.getValue(identifier);
|
||||
if (biome != null) {
|
||||
biomes.add(ModdedBiome.of(biome, identifier.toString()));
|
||||
|
||||
RegistryCache cached = cacheFor(registry);
|
||||
List<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) {
|
||||
built.add(ModdedBiome.of(biome, identifier.toString()));
|
||||
}
|
||||
}
|
||||
snapshot = List.copyOf(built);
|
||||
cached.biomes = snapshot;
|
||||
}
|
||||
return biomes;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-4
@@ -68,6 +68,14 @@ public final class ModdedBlockBreakHandler {
|
||||
if (engineFor(level) == null) {
|
||||
return;
|
||||
}
|
||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||
if (scheduler == null) {
|
||||
// finishPending is the only thing that evicts an unconsumed entry. With no scheduler there is no
|
||||
// sweep, so an entry inserted here would leak for the rest of the server uptime.
|
||||
LOGGER.debug("Iris skipped block-break provenance at {},{},{}: scheduler unavailable",
|
||||
position.getX(), position.getY(), position.getZ());
|
||||
return;
|
||||
}
|
||||
BreakKey key = new BreakKey(level, position.asLong());
|
||||
ModdedTreeFellerService treeFeller = treeFellerService();
|
||||
ModdedTreeFellerService.PreparedOrigin preparedOrigin = treeFeller == null
|
||||
@@ -75,10 +83,7 @@ public final class ModdedBlockBreakHandler {
|
||||
: treeFeller.prepare(level, player, position, brokenState);
|
||||
PendingBreak pending = new PendingBreak(brokenState, preparedOrigin);
|
||||
PENDING.put(key, pending);
|
||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||
if (scheduler != null) {
|
||||
scheduler.laterGlobal(() -> finishPending(key, pending, position), 1);
|
||||
}
|
||||
scheduler.laterGlobal(() -> finishPending(key, pending, position), 1);
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
|
||||
+29
-9
@@ -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;
|
||||
|
||||
+8
-1
@@ -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) {
|
||||
|
||||
+56
@@ -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<>());
|
||||
|
||||
+9
-11
@@ -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;
|
||||
|
||||
+255
-20
@@ -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) {
|
||||
pool.shutdownNow();
|
||||
ExecutorService pool = GEN_POOL.getAndSet(null);
|
||||
if (pool == null) {
|
||||
return;
|
||||
}
|
||||
pool.shutdown();
|
||||
try {
|
||||
if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) {
|
||||
return;
|
||||
}
|
||||
LOGGER.debug("Iris gen pool did not drain in {}ms, forcing shutdown", SHUTDOWN_DRAIN_MILLIS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
pool.shutdownNow();
|
||||
}
|
||||
|
||||
private static ChunkSystem detectChunkSystem() {
|
||||
ChunkSystem detected = probeC2ME();
|
||||
if (detected == null) {
|
||||
detected = probeMoonrise();
|
||||
}
|
||||
if (detected == null) {
|
||||
detected = new ChunkSystem(false, "vanilla");
|
||||
}
|
||||
LOGGER.info("Iris chunk system: {} (parallel={}, generation on {})",
|
||||
detected.description(),
|
||||
detected.parallel() ? "yes" : "no",
|
||||
detected.parallel() ? "loader threads" : "Iris gen pool");
|
||||
return detected;
|
||||
}
|
||||
|
||||
private static ChunkSystem probeC2ME() {
|
||||
if (!anyPresent(C2ME_MARKERS)) {
|
||||
return null;
|
||||
}
|
||||
Class<?> config = loadOrNull(C2ME_CONFIG);
|
||||
if (config == null) {
|
||||
return new ChunkSystem(false, "c2me present, config class missing");
|
||||
}
|
||||
Boolean enabled = readSectionFlag(config, C2ME_SECTIONS, C2ME_FLAGS);
|
||||
if (enabled == null) {
|
||||
return new ChunkSystem(false, "c2me present, async scheduling unreadable");
|
||||
}
|
||||
return new ChunkSystem(enabled, enabled ? "c2me async scheduling on" : "c2me async scheduling off");
|
||||
}
|
||||
|
||||
private static ChunkSystem probeMoonrise() {
|
||||
Class<?> marker = loadOrNull(MOONRISE_MARKER);
|
||||
if (marker == null) {
|
||||
return null;
|
||||
}
|
||||
Integer workers = readMoonriseWorkers(marker);
|
||||
if (workers == null) {
|
||||
return new ChunkSystem(false, "moonrise present, worker pool unreadable");
|
||||
}
|
||||
if (workers <= 0) {
|
||||
return new ChunkSystem(false, "moonrise worker pool empty");
|
||||
}
|
||||
return new ChunkSystem(true, "moonrise workers=" + workers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads {@code <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"
|
||||
};
|
||||
private static Method declaredMethodOrNull(Class<?> type, String name) {
|
||||
try {
|
||||
Method method = type.getDeclaredMethod(name);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
} catch (NoSuchMethodException e) {
|
||||
return null;
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Iris chunk system probe could not access method {}.{}(): {}", type.getName(), name, e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean anyPresent(String[] markers) {
|
||||
for (String marker : markers) {
|
||||
try {
|
||||
Class.forName(marker, false, IrisModdedChunkGenerator.class.getClassLoader());
|
||||
if (loadOrNull(marker) != null) {
|
||||
return true;
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Class<?> loadOrNull(String name) {
|
||||
try {
|
||||
return Class.forName(name, false, ModdedGenPool.class.getClassLoader());
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Iris chunk system probe: {} absent ({})", name, e.getClass().getSimpleName());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static ExecutorService createGenPool() {
|
||||
int threads = Math.max(2, Runtime.getRuntime().availableProcessors());
|
||||
ThreadPoolExecutor pool = new ThreadPoolExecutor(
|
||||
@@ -84,4 +354,7 @@ final class ModdedGenPool {
|
||||
pool.allowCoreThreadTimeOut(true);
|
||||
return pool;
|
||||
}
|
||||
|
||||
private record ChunkSystem(boolean parallel, String description) {
|
||||
}
|
||||
}
|
||||
|
||||
+435
@@ -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) {
|
||||
}
|
||||
}
|
||||
+5
-24
@@ -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;
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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());
|
||||
|
||||
+19
-11
@@ -56,18 +56,26 @@ public final class ModdedPackInstaller {
|
||||
synchronized (installLock) {
|
||||
File packs = configDir.resolve("irisworldgen").resolve("packs").toFile();
|
||||
try {
|
||||
if (PackDownloader.isDefaultOverworld(pack)) {
|
||||
return PackDownloader.downloadDefaultOverworld(
|
||||
packs, forceOverwrite, feedback) != null;
|
||||
boolean installed = PackDownloader.isDefaultOverworld(pack)
|
||||
? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback) != null
|
||||
: PackDownloader.download(
|
||||
packs,
|
||||
"IrisDimensions/" + pack,
|
||||
branch,
|
||||
forceOverwrite,
|
||||
false,
|
||||
feedback) != null;
|
||||
if (installed) {
|
||||
// Pack-install completion is one of the four forced-datapack regeneration triggers; every
|
||||
// install call site already runs off the server thread, so regenerate inline here. A
|
||||
// regeneration failure must never turn a successful install into a failed one.
|
||||
try {
|
||||
ModdedForcedDatapack.regenerateIfStale("pack install " + pack);
|
||||
} catch (Throwable regenerationFailure) {
|
||||
LOGGER.error("Iris installed pack '{}' but could not regenerate the forced datapack", pack, regenerationFailure);
|
||||
}
|
||||
}
|
||||
return PackDownloader.download(
|
||||
packs,
|
||||
"IrisDimensions/" + pack,
|
||||
branch,
|
||||
forceOverwrite,
|
||||
false,
|
||||
feedback
|
||||
) != null;
|
||||
return installed;
|
||||
} catch (IOException error) {
|
||||
LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error);
|
||||
feedback.accept(IrisLanguage.plain(
|
||||
|
||||
@@ -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;
|
||||
|
||||
+10
@@ -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;
|
||||
|
||||
+52
-13
@@ -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) {
|
||||
|
||||
+22
-2
@@ -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,30 +166,54 @@ public final class ModdedScheduler implements PlatformScheduler {
|
||||
mainQueue.clear();
|
||||
delayedQueue.clear();
|
||||
mainThread = null;
|
||||
// Shutdown stage that always runs (ModdedEngineBootstrap.stop): release the level snapshot with it.
|
||||
ModdedServerLevels.forget();
|
||||
}
|
||||
|
||||
private void warnOnBacklog(ThreadPoolExecutor executor) {
|
||||
int queued = executor.getQueue().size();
|
||||
if (queued < ASYNC_BACKLOG_WARN) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
long last = lastBacklogWarnAt.get();
|
||||
if (now - last < ASYNC_BACKLOG_WARN_INTERVAL_MILLIS || !lastBacklogWarnAt.compareAndSet(last, now)) {
|
||||
return;
|
||||
}
|
||||
LOGGER.warn("Iris async backlog {} tasks (threads={}); async work is falling behind", queued, executor.getPoolSize());
|
||||
}
|
||||
|
||||
private void drain() {
|
||||
promoteDelayed();
|
||||
long tick = currentTick.incrementAndGet();
|
||||
promoteDelayed(tick);
|
||||
long deadline = System.nanoTime() + DRAIN_BUDGET_NANOS;
|
||||
int executed = 0;
|
||||
Runnable task;
|
||||
while ((task = mainQueue.poll()) != null) {
|
||||
runGuarded(task);
|
||||
executed++;
|
||||
if (executed >= DRAIN_TASK_CAP || System.nanoTime() >= deadline) {
|
||||
// Budget spent; the remainder stays queued in order and runs next tick.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void promoteDelayed() {
|
||||
if (delayedQueue.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<DelayedTask> retained = new ArrayList<>();
|
||||
DelayedTask delayed;
|
||||
while ((delayed = delayedQueue.poll()) != null) {
|
||||
if (delayed.tick()) {
|
||||
mainQueue.add(delayed.task());
|
||||
} else {
|
||||
retained.add(delayed);
|
||||
/**
|
||||
* Due-ordered promotion; only the tick thread polls, so the head is always the earliest due task. A task
|
||||
* added while this runs is due at least one tick after the tick being promoted, so it sorts behind
|
||||
* everything this pass drains and is picked up on a later tick instead of being skipped. No per-tick
|
||||
* rebuild of the pending set.
|
||||
*/
|
||||
private void promoteDelayed(long tick) {
|
||||
DelayedTask head;
|
||||
while ((head = delayedQueue.peek()) != null && head.dueTick() <= tick) {
|
||||
DelayedTask delayed = delayedQueue.poll();
|
||||
if (delayed == null) {
|
||||
return;
|
||||
}
|
||||
mainQueue.add(delayed.task());
|
||||
}
|
||||
delayedQueue.addAll(retained);
|
||||
}
|
||||
|
||||
private boolean onMainThread() {
|
||||
@@ -174,22 +229,11 @@ public final class ModdedScheduler implements PlatformScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DelayedTask {
|
||||
private final Runnable task;
|
||||
private int remaining;
|
||||
|
||||
private DelayedTask(Runnable task, int remaining) {
|
||||
this.task = task;
|
||||
this.remaining = remaining;
|
||||
}
|
||||
|
||||
private boolean tick() {
|
||||
remaining--;
|
||||
return remaining <= 0;
|
||||
}
|
||||
|
||||
private Runnable task() {
|
||||
return task;
|
||||
private record DelayedTask(long dueTick, long sequence, Runnable task) implements Comparable<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) {
|
||||
}
|
||||
}
|
||||
|
||||
+9
-3
@@ -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;
|
||||
}
|
||||
|
||||
+78
-24
@@ -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() {
|
||||
|
||||
+159
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
|
||||
+14
-4
@@ -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++;
|
||||
}
|
||||
|
||||
+45
-2
@@ -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) {
|
||||
|
||||
+2
-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());
|
||||
}
|
||||
|
||||
+24
-7
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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;
|
||||
}
|
||||
|
||||
+28
-3
@@ -26,6 +26,8 @@ import art.arcane.iris.modded.ModdedBlockResolution;
|
||||
import art.arcane.iris.modded.ModdedBlockState;
|
||||
import art.arcane.iris.modded.ModdedTileData;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
@@ -43,6 +45,7 @@ import java.util.Map;
|
||||
|
||||
final class ModdedObjectPlacer implements IObjectPlacer {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||
private static final int DEFAULT_FLUID_HEIGHT = 63;
|
||||
|
||||
private final ServerLevel level;
|
||||
private final Engine engine;
|
||||
@@ -116,9 +119,21 @@ final class ModdedObjectPlacer implements IObjectPlacer {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mantle Y is relative to the world minimum height while this placer works in absolute world Y, so shift
|
||||
* before the lookup. Only answers from an already loaded mantle chunk: a hand placed object can sit
|
||||
* anywhere, and loading a mantle chunk to answer a carve probe would generate terrain as a side effect.
|
||||
*/
|
||||
@Override
|
||||
public boolean isCarved(int x, int y, int z) {
|
||||
return false;
|
||||
if (engine == null) {
|
||||
return false;
|
||||
}
|
||||
Mantle<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
|
||||
@@ -126,14 +141,24 @@ final class ModdedObjectPlacer implements IObjectPlacer {
|
||||
return ModdedBlockResolution.isSolid(level.getBlockState(new BlockPos(x, y, z)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Engine height stream against the dimension fluid height, both engine relative, so no shift here. Needs a
|
||||
* ready complex; the placer also runs from commands against levels that never bound an engine.
|
||||
*/
|
||||
@Override
|
||||
public boolean isUnderwater(int x, int z) {
|
||||
return false;
|
||||
return engine != null && engine.getComplex() != null && engine.getMantle().isUnderwater(x, z);
|
||||
}
|
||||
|
||||
/**
|
||||
* IrisDimension fluid height is engine relative while this placer works in absolute world Y, so shift it
|
||||
* up by the engine minimum before handing it to object placement.
|
||||
*/
|
||||
@Override
|
||||
public int getFluidHeight() {
|
||||
return 63;
|
||||
return engine == null
|
||||
? DEFAULT_FLUID_HEIGHT
|
||||
: engine.getMinHeight() + engine.getDimension().getFluidHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+187
-49
@@ -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,33 +103,38 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
|
||||
@Override
|
||||
public void init() {
|
||||
LOGGER.info("Iris modded pregen init: dim={} mode={} inFlightCap={} timeout={}s workerPool={} parallelChunkSystem={}",
|
||||
pauseGuard.suspend();
|
||||
LOGGER.info("Iris modded pregen init: dim={} mode={} inFlightCap={} timeout={}s workerPool={} chunkSystem={}",
|
||||
level.dimension().identifier(),
|
||||
sync ? "sync" : "async",
|
||||
sync ? 1 : maxInFlight,
|
||||
timeoutSeconds,
|
||||
describeWorkerPool(),
|
||||
PARALLEL_CHUNK_SYSTEM ? "yes" : "no");
|
||||
if (!sync && !PARALLEL_CHUNK_SYSTEM) {
|
||||
ModdedGenPool.describeChunkSystem());
|
||||
if (!sync && !ModdedGenPool.parallelChunkSystem()) {
|
||||
LOGGER.info("Iris pregen note: this loader uses the vanilla main-thread chunk system, which caps pregen throughput. For Bukkit-level speed on Fabric install C2ME (Concurrent Chunk Management Engine); on servers use Paper.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!sync) {
|
||||
try {
|
||||
semaphore.tryAcquire(maxInFlight, 5, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
try {
|
||||
if (!sync) {
|
||||
try {
|
||||
semaphore.tryAcquire(maxInFlight, 5, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
LOGGER.info("Iris modded pregen done: dim={} completed={} peakInFlight={} finalLimit={}",
|
||||
level.dimension().identifier(), completed.get(), inFlightPeak.get(), adaptiveLimit.get());
|
||||
if (deferFinalSaveIfRequested()) {
|
||||
return;
|
||||
}
|
||||
saveLevel(true);
|
||||
} finally {
|
||||
pauseGuard.restore();
|
||||
}
|
||||
LOGGER.info("Iris modded pregen done: dim={} completed={} peakInFlight={} finalLimit={}",
|
||||
level.dimension().identifier(), completed.get(), inFlightPeak.get(), adaptiveLimit.get());
|
||||
if (deferFinalSaveIfRequested()) {
|
||||
return;
|
||||
}
|
||||
saveLevel(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -337,6 +346,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (TimeoutException | ExecutionException e) {
|
||||
if (e instanceof TimeoutException) {
|
||||
noteStallHint();
|
||||
}
|
||||
LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, e.toString());
|
||||
listener.onChunkFailed(x, z);
|
||||
} finally {
|
||||
@@ -413,11 +425,22 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
}
|
||||
|
||||
private void onTimeout() {
|
||||
noteStallHint();
|
||||
if (timeoutStreak.incrementAndGet() % ADAPTIVE_TIMEOUT_STEP == 0) {
|
||||
adjustAdaptiveLimit(-1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First timeout of a job explains itself if the server is able to stop ticking under us. Without
|
||||
* this a paused server just produces a wall of identical chunk timeouts.
|
||||
*/
|
||||
private void noteStallHint() {
|
||||
if (stallHintLogged.compareAndSet(false, true)) {
|
||||
pauseGuard.logStallHint();
|
||||
}
|
||||
}
|
||||
|
||||
private void onSuccess() {
|
||||
int streak = timeoutStreak.get();
|
||||
if (streak > 0) {
|
||||
@@ -455,56 +478,171 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
||||
private void cleanupMantleChunk(int x, int z) {
|
||||
try {
|
||||
engine.getMantle().forceCleanupChunk(x, z);
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable e) {
|
||||
LOGGER.debug("Iris pregen mantle cleanup skipped for {},{}: {}", x, z, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private String describeWorkerPool() {
|
||||
try {
|
||||
Field field = MinecraftServer.class.getDeclaredField("executor");
|
||||
field.setAccessible(true);
|
||||
Object exec = field.get(level.getServer());
|
||||
if (exec == null) {
|
||||
return "unknown";
|
||||
}
|
||||
if (exec instanceof ThreadPoolExecutor tpe) {
|
||||
return "ThreadPoolExecutor(core=" + tpe.getCorePoolSize() + ",max=" + tpe.getMaximumPoolSize() + ")";
|
||||
}
|
||||
if (exec instanceof ForkJoinPool fjp) {
|
||||
return "ForkJoinPool(parallelism=" + fjp.getParallelism() + ")";
|
||||
}
|
||||
return exec.getClass().getSimpleName();
|
||||
} catch (Throwable e) {
|
||||
Executor exec = level.getServer().executor;
|
||||
if (exec == null) {
|
||||
return "unknown";
|
||||
}
|
||||
if (exec instanceof ThreadPoolExecutor tpe) {
|
||||
return "ThreadPoolExecutor(core=" + tpe.getCorePoolSize() + ",max=" + tpe.getMaximumPoolSize() + ")";
|
||||
}
|
||||
if (exec instanceof ForkJoinPool fjp) {
|
||||
return "ForkJoinPool(parallelism=" + fjp.getParallelism() + ")";
|
||||
}
|
||||
return exec.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
private static Throwable unwrap(Throwable error) {
|
||||
return error != null && error.getCause() != null ? error.getCause() : error;
|
||||
}
|
||||
|
||||
private static boolean detectParallelChunkSystem() {
|
||||
String[] markers = {
|
||||
"com.ishland.c2me.base.ModProperties",
|
||||
"com.ishland.c2me.base.common.config.C2MEConfig",
|
||||
"com.ishland.c2me.opts.chunkio.ModProperties",
|
||||
"ca.spottedleaf.moonrise.common.util.MoonriseCommon"
|
||||
};
|
||||
for (String marker : markers) {
|
||||
try {
|
||||
Class.forName(marker, false, ModdedPregenMethod.class.getClassLoader());
|
||||
return true;
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mantle getMantle() {
|
||||
return engine.getMantle().getMantle();
|
||||
}
|
||||
|
||||
/**
|
||||
* A dedicated server with {@code pause-when-empty-seconds > 0} returns from
|
||||
* {@code MinecraftServer#tickServer} before {@code tickChildren} once it has been empty for that
|
||||
* long (26.2 only keeps {@code tickConnection} plus the task/chunk-poll window alive). That
|
||||
* freezes every per-tick Iris service - world manager, scheduler, protocol sync, pregen HUD - and
|
||||
* the loader's own generation hooks for the whole job, and console pregen on a default
|
||||
* server.properties is always empty. The guard zeroes the setting for the duration of the job
|
||||
* through the vanilla public accessors
|
||||
* ({@code DedicatedServer#pauseWhenEmptySeconds}/{@code #setPauseWhenEmptySeconds}, both widened
|
||||
* to public by Mojang for the management API) and restores the previous value on completion or
|
||||
* abort. No reflection and no access widener, identical on all three loaders. An integrated
|
||||
* (singleplayer) server never pauses on empty - {@code MinecraftServer#pauseWhenEmptySeconds}
|
||||
* returns 0 there - so it is skipped silently.
|
||||
*
|
||||
* <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);
|
||||
|
||||
+32
-2
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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());
|
||||
}
|
||||
|
||||
+2
@@ -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()));
|
||||
}
|
||||
|
||||
+2
@@ -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();
|
||||
}
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
+51
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+160
@@ -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);
|
||||
}
|
||||
}
|
||||
+193
@@ -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();
|
||||
}
|
||||
}
|
||||
+149
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
@@ -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();
|
||||
|
||||
+94
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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());
|
||||
}
|
||||
}
|
||||
+52
@@ -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());
|
||||
}
|
||||
}
|
||||
+103
@@ -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;
|
||||
}
|
||||
}
|
||||
+53
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -151,7 +151,8 @@ public class ModdedLifecycleFailureContractTest {
|
||||
assertBefore(stop, "\"services\"", "\"world engines\"");
|
||||
assertBefore(stop, "\"world engines\"", "\"dimension manager\"");
|
||||
assertBefore(stop, "\"server state\"", "if (failure != null)");
|
||||
assertTrue(stop.contains("throw propagateStopFailure(failure);"));
|
||||
assertFalse(stop.contains("throw"));
|
||||
assertTrue(stop.contains("LOGGER.error(\"Iris modded shutdown completed with failures\", failure);"));
|
||||
|
||||
String runStage = method(source, "private static Throwable runStopStage(");
|
||||
assertTrue(runStage.contains("catch (Throwable stageFailure)"));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user