mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 12:41:43 +00:00
F
This commit is contained in:
@@ -33,6 +33,29 @@ workspaces with schema autocomplete over the server's live registries, entity sp
|
|||||||
including death loot, pregeneration with a boss bar or client HUD, the `/iris` command tree, and
|
including death loot, pregeneration with a boss bar or client HUD, the `/iris` command tree, and
|
||||||
the goldenhash determinism gate, which is interchangeable across all four platforms.
|
the goldenhash determinism gate, which is interchangeable across all four platforms.
|
||||||
|
|
||||||
|
### Native worldgen over Iris terrain
|
||||||
|
|
||||||
|
Iris replaces the chunk generator, so vanilla and mod worldgen only runs where Iris runs it. This
|
||||||
|
is identical on every platform.
|
||||||
|
|
||||||
|
| Vanilla / mod worldgen | Over Iris terrain | Control |
|
||||||
|
|---|---|---|
|
||||||
|
| Structures (vanilla, datapack, mod) | Yes, on by default | `importedStructures.disabled` denies individual keys |
|
||||||
|
| Placed features: ores, trees, plants, springs, geodes | Yes, **off by default** | `importedFeatures.enabled` per dimension, with per-step and per-key filters |
|
||||||
|
| Carvers (caves, canyons, mod carvers) | Never - architectural | Iris has no `NoiseGeneratorSettings` for a carver to sample; use pack `caves`/`carvings` |
|
||||||
|
| Surface builders and surface rules | Never | Iris builds its surface from pack palettes |
|
||||||
|
| Mod biomes | Only as a `derivative`, `vanillaDerivative`, `biomeScatter` or `biomeSkyScatter` target | Iris chooses biomes from the pack, not from a biome source |
|
||||||
|
| Mob spawning, including mod mobs | Yes | Biome spawn tables are merged with the vanilla derivative's |
|
||||||
|
|
||||||
|
With `importedFeatures` off - the default - chunk output is byte-for-byte what Iris has always
|
||||||
|
produced. See [docs/api/modded.md](docs/api/modded.md) for the full control reference, including
|
||||||
|
which `pointed_dripstone` keys the 26.2 `speleothem` rename does and does not affect.
|
||||||
|
|
||||||
|
Independently of that flag, Iris custom biomes now inherit the biome tags of their vanilla
|
||||||
|
derivative on every platform, so the emitted datapack tag files change. Anything driven by biome
|
||||||
|
tags therefore applies to Iris custom biomes: mob variants, spawn rules, and any vanilla or mod
|
||||||
|
content selecting on `#minecraft:is_overworld` and friends.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
**Plugin (Paper/Purpur/Leaf/Canvas/Folia/Spigot):** drop the plugin jar into `plugins/` and start
|
**Plugin (Paper/Purpur/Leaf/Canvas/Folia/Spigot):** drop the plugin jar into `plugins/` and start
|
||||||
@@ -42,7 +65,7 @@ the server. On first boot Iris downloads the default `overworld` pack automatica
|
|||||||
self-contained (core, SPI, and required Fabric API modules are bundled). On first boot Iris
|
self-contained (core, SPI, and required Fabric API modules are bundled). On first boot Iris
|
||||||
downloads the default `overworld` pack before the worldgen datapack is written, so the default
|
downloads the default `overworld` pack before the worldgen datapack is written, so the default
|
||||||
pack is fully active immediately. Packs installed later register their custom dimension types
|
pack is fully active immediately. Packs installed later register their custom dimension types
|
||||||
(height ranges) and custom biomes through the forced datapack at server start — restart once after
|
(height ranges) and custom biomes through the forced datapack at server start - restart once after
|
||||||
adding a pack so worlds get its full heights and biomes; worlds created before that restart run
|
adding a pack so worlds get its full heights and biomes; worlds created before that restart run
|
||||||
with fallback heights.
|
with fallback heights.
|
||||||
|
|
||||||
@@ -91,7 +114,7 @@ console/status output. `/iris pregen status` reports progress on the plugin.
|
|||||||
## Studio and VSCode workspace
|
## Studio and VSCode workspace
|
||||||
|
|
||||||
The studio is the pack authoring environment, available on all platforms. Studio worlds are
|
The studio is the pack authoring environment, available on all platforms. Studio worlds are
|
||||||
transient — they are deleted on close and purged at startup.
|
transient - they are deleted on close and purged at startup.
|
||||||
|
|
||||||
```
|
```
|
||||||
/iris studio create <name> [template] scaffold a new pack (default template: example)
|
/iris studio create <name> [template] scaffold a new pack (default template: example)
|
||||||
@@ -142,8 +165,8 @@ returns `---`. A real zero returns `0`.
|
|||||||
The world values are the surface reading at the player's block column. Walking refreshes them at most
|
The world values are the surface reading at the player's block column. Walking refreshes them at most
|
||||||
once per second per player, so a whole board of `world.*` keys costs one refresh per player per
|
once per second per player, so a whole board of `world.*` keys costs one refresh per player per
|
||||||
second no matter how many of them are on it, and a value may lag a sprinting player by up to a
|
second no matter how many of them are on it, and a value may lag a sprinting player by up to a
|
||||||
second. A jump that is not walking — joining, respawning, changing worlds, stepping through a portal,
|
second. A jump that is not walking - joining, respawning, changing worlds, stepping through a portal,
|
||||||
or any teleport including `/iris goto`, `/tp`, an ender pearl and a random teleport — is published
|
or any teleport including `/iris goto`, `/tp`, an ender pearl and a random teleport - is published
|
||||||
immediately, so a player who arrives somewhere and then stands still never keeps reading the biome,
|
immediately, so a player who arrives somewhere and then stands still never keeps reading the biome,
|
||||||
region or dimension of where they came from. `pregen.*` is global: there is one pregeneration job per
|
region or dimension of where they came from. `pregen.*` is global: there is one pregeneration job per
|
||||||
server, and `%iris_pregen.world%` says which world it is.
|
server, and `%iris_pregen.world%` says which world it is.
|
||||||
|
|||||||
@@ -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
|
[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
|
||||||
[23:25:34] [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 -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 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.StructureManager;
|
||||||
import net.minecraft.world.level.WorldGenLevel;
|
import net.minecraft.world.level.WorldGenLevel;
|
||||||
import net.minecraft.world.level.biome.Biome;
|
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.BiomeManager;
|
||||||
import net.minecraft.world.level.biome.BiomeSource;
|
import net.minecraft.world.level.biome.BiomeSource;
|
||||||
import net.minecraft.world.level.biome.MobSpawnSettings;
|
import net.minecraft.world.level.biome.MobSpawnSettings;
|
||||||
@@ -106,6 +107,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
|||||||
private final int runtimeHeight;
|
private final int runtimeHeight;
|
||||||
private final int runtimeSeaLevel;
|
private final int runtimeSeaLevel;
|
||||||
private final ConcurrentHashMap<SpawnTableKey, WeightedList<MobSpawnSettings.SpawnerData>> mergedSpawnTables = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<SpawnTableKey, WeightedList<MobSpawnSettings.SpawnerData>> mergedSpawnTables = new ConcurrentHashMap<>();
|
||||||
|
private final ImportedFeatureStage importedFeatures;
|
||||||
private volatile ReachableStructureCache reachableStructureCache;
|
private volatile ReachableStructureCache reachableStructureCache;
|
||||||
private volatile StructureStepCache structureStepCache;
|
private volatile StructureStepCache structureStepCache;
|
||||||
|
|
||||||
@@ -118,6 +120,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
|||||||
this.delegate = delegate;
|
this.delegate = delegate;
|
||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
this.customBiomeSource = customBiomeSource;
|
this.customBiomeSource = customBiomeSource;
|
||||||
|
this.importedFeatures = new ImportedFeatureStage(engine);
|
||||||
ServerLevel level = ((CraftWorld) world).getHandle();
|
ServerLevel level = ((CraftWorld) world).getHandle();
|
||||||
this.runtimeMinY = level.getMinY();
|
this.runtimeMinY = level.getMinY();
|
||||||
this.runtimeHeight = level.getHeight();
|
this.runtimeHeight = level.getHeight();
|
||||||
@@ -425,14 +428,33 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void applyBiomeDecoration(WorldGenLevel generatoraccessseed, ChunkAccess ichunkaccess, StructureManager structuremanager, boolean vanilla) {
|
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");
|
try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_biome_decoration");
|
||||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||||
addVanillaDecorations(generatoraccessseed, ichunkaccess, structuremanager);
|
addVanillaDecorations(generatoraccessseed, ichunkaccess, structuremanager);
|
||||||
placeVanillaStructures(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);
|
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) {
|
private void placeVanillaStructures(WorldGenLevel world, ChunkAccess chunk, StructureManager structureManager) {
|
||||||
if (!structureManager.shouldGenerateStructures()) {
|
if (!structureManager.shouldGenerateStructures()) {
|
||||||
ChunkPos disabledChunk = chunk.getPos();
|
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 {
|
tasks.named('jar', Jar).configure {
|
||||||
archiveBaseName.set('iris-bukkit-plugin')
|
archiveBaseName.set('iris-bukkit-plugin')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,11 +231,12 @@ public class CommandSVC implements IrisService, CommandExecutor, TabCompleter, D
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
DirectorMiniMenu.deliver(sender, DirectorMiniMenu.render(
|
DirectorMiniMenu.deliver(
|
||||||
|
sender,
|
||||||
request.get(),
|
request.get(),
|
||||||
DirectorMiniMenu.Theme.irisGreen(),
|
DirectorMiniMenu.Theme.irisGreen(),
|
||||||
IrisLanguage.directorResolver()
|
IrisLanguage.directorResolver()
|
||||||
));
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -52,6 +52,7 @@ public class IrisProtocolService implements IrisService, PluginMessageListener,
|
|||||||
|
|
||||||
private IrisSessionRegistry registry;
|
private IrisSessionRegistry registry;
|
||||||
private IrisProtocolServer protocolServer;
|
private IrisProtocolServer protocolServer;
|
||||||
|
private IrisVisionRequestService visionService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -62,7 +63,8 @@ public class IrisProtocolService implements IrisService, PluginMessageListener,
|
|||||||
protocolServer = new IrisProtocolServer(registry, SERVER_CAPABILITIES, brand(), true);
|
protocolServer = new IrisProtocolServer(registry, SERVER_CAPABILITIES, brand(), true);
|
||||||
EngineResolver engineResolver = IrisProtocolService::resolveEngine;
|
EngineResolver engineResolver = IrisProtocolService::resolveEngine;
|
||||||
protocolServer.setEngineResolver(engineResolver);
|
protocolServer.setEngineResolver(engineResolver);
|
||||||
protocolServer.setVisionTileHandler(IrisVisionRequestService.create(engineResolver, registry));
|
visionService = IrisVisionRequestService.create(engineResolver, registry);
|
||||||
|
protocolServer.setVisionTileHandler(visionService);
|
||||||
IrisServices.register(IrisProtocolServer.class, protocolServer);
|
IrisServices.register(IrisProtocolServer.class, protocolServer);
|
||||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||||
registry.register(new IrisSession(player.getUniqueId().toString(), this));
|
registry.register(new IrisSession(player.getUniqueId().toString(), this));
|
||||||
@@ -83,6 +85,7 @@ public class IrisProtocolService implements IrisService, PluginMessageListener,
|
|||||||
}
|
}
|
||||||
registry = null;
|
registry = null;
|
||||||
protocolServer = null;
|
protocolServer = null;
|
||||||
|
visionService = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventHandler
|
@EventHandler
|
||||||
@@ -109,7 +112,12 @@ public class IrisProtocolService implements IrisService, PluginMessageListener,
|
|||||||
if (current == null) {
|
if (current == null) {
|
||||||
return;
|
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
|
@Override
|
||||||
|
|||||||
@@ -2,12 +2,26 @@ package art.arcane.iris.client;
|
|||||||
|
|
||||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
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 {
|
public final class IrisClientHud {
|
||||||
private 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();
|
IrisToastPresenter.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void render(GuiGraphicsExtractor graphics) {
|
||||||
IrisPregenHud.render(graphics);
|
IrisPregenHud.render(graphics);
|
||||||
IrisWhatOverlay.render(graphics);
|
IrisWhatOverlay.render(graphics);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import net.minecraft.client.KeyMapping;
|
|||||||
import net.minecraft.client.Minecraft;
|
import net.minecraft.client.Minecraft;
|
||||||
import org.lwjgl.glfw.GLFW;
|
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 {
|
public final class IrisClientKeybinds {
|
||||||
private static final KeyMapping.Category CATEGORY = KeyMapping.Category.register(IrisClient.KEYBIND_CATEGORY_ID);
|
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);
|
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 art.arcane.iris.spi.protocol.IrisMessage;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
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 {
|
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() {
|
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()));
|
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);
|
return byTile.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void clear() {
|
public synchronized int trackedTiles() {
|
||||||
|
return byTile.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void clear() {
|
||||||
byTile.clear();
|
byTile.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-15
@@ -2,23 +2,47 @@ package art.arcane.iris.client;
|
|||||||
|
|
||||||
import art.arcane.iris.spi.protocol.IrisMessage;
|
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 {
|
public final class IrisClientPregenState {
|
||||||
private final ConcurrentHashMap<Long, IrisMessage.PregenProgress> jobs;
|
/** Beyond this the panel reads as unreliable and the HUD mutes it. */
|
||||||
private volatile Long activeJobId;
|
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() {
|
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;
|
this.activeJobId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void onProgress(IrisMessage.PregenProgress progress) {
|
public synchronized void onProgress(IrisMessage.PregenProgress progress) {
|
||||||
jobs.put(progress.jobId(), progress);
|
jobs.put(progress.jobId(), new Entry(progress, clock.getAsLong()));
|
||||||
activeJobId = progress.jobId();
|
activeJobId = progress.jobId();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void onEnd(long jobId) {
|
public synchronized void onEnd(long jobId) {
|
||||||
jobs.remove(jobId);
|
jobs.remove(jobId);
|
||||||
Long current = activeJobId;
|
Long current = activeJobId;
|
||||||
if (current != null && current == jobId) {
|
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;
|
Long current = activeJobId;
|
||||||
if (current == null) {
|
if (current == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -34,12 +92,6 @@ public final class IrisClientPregenState {
|
|||||||
return jobs.get(current);
|
return jobs.get(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long activeJobId() {
|
private record Entry(IrisMessage.PregenProgress progress, long receivedAtMillis) {
|
||||||
return activeJobId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void clear() {
|
|
||||||
jobs.clear();
|
|
||||||
activeJobId = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public final class IrisClientSession {
|
|||||||
private final LongSupplier clock;
|
private final LongSupplier clock;
|
||||||
private volatile State state;
|
private volatile State state;
|
||||||
private volatile long serverCapabilities;
|
private volatile long serverCapabilities;
|
||||||
|
private volatile int serverProtocolVersion;
|
||||||
private volatile boolean irisActive;
|
private volatile boolean irisActive;
|
||||||
private volatile String serverBrand;
|
private volatile String serverBrand;
|
||||||
private volatile ClientPacketSink sink;
|
private volatile ClientPacketSink sink;
|
||||||
@@ -28,6 +29,7 @@ public final class IrisClientSession {
|
|||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
this.state = State.IDLE;
|
this.state = State.IDLE;
|
||||||
this.serverCapabilities = 0L;
|
this.serverCapabilities = 0L;
|
||||||
|
this.serverProtocolVersion = 0;
|
||||||
this.irisActive = false;
|
this.irisActive = false;
|
||||||
this.serverBrand = "";
|
this.serverBrand = "";
|
||||||
this.sink = null;
|
this.sink = null;
|
||||||
@@ -55,6 +57,11 @@ public final class IrisClientSession {
|
|||||||
return serverCapabilities;
|
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() {
|
public String serverBrand() {
|
||||||
return serverBrand;
|
return serverBrand;
|
||||||
}
|
}
|
||||||
@@ -79,6 +86,11 @@ public final class IrisClientSession {
|
|||||||
private void sendHelloAttempt() {
|
private void sendHelloAttempt() {
|
||||||
ClientPacketSink activeSink = sink;
|
ClientPacketSink activeSink = sink;
|
||||||
if (activeSink == null) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
byte[] frame = IrisMessageCodec.encode(new IrisMessage.ClientHello(IrisProtocol.PROTOCOL_VERSION, CLIENT_CAPABILITIES));
|
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) {
|
public void onServerHello(IrisMessage.ServerHello hello) {
|
||||||
|
this.serverProtocolVersion = hello.protocolVersion();
|
||||||
if (hello.protocolVersion() != IrisProtocol.PROTOCOL_VERSION) {
|
if (hello.protocolVersion() != IrisProtocol.PROTOCOL_VERSION) {
|
||||||
|
this.serverBrand = hello.serverBrand();
|
||||||
this.state = State.INCOMPATIBLE;
|
this.state = State.INCOMPATIBLE;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -102,6 +116,7 @@ public final class IrisClientSession {
|
|||||||
public void reset() {
|
public void reset() {
|
||||||
this.state = State.IDLE;
|
this.state = State.IDLE;
|
||||||
this.serverCapabilities = 0L;
|
this.serverCapabilities = 0L;
|
||||||
|
this.serverProtocolVersion = 0;
|
||||||
this.irisActive = false;
|
this.irisActive = false;
|
||||||
this.serverBrand = "";
|
this.serverBrand = "";
|
||||||
this.nextHelloAt = Long.MAX_VALUE;
|
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.IrisMessage;
|
||||||
import art.arcane.iris.spi.protocol.IrisMessageCodec;
|
import art.arcane.iris.spi.protocol.IrisMessageCodec;
|
||||||
import art.arcane.iris.spi.protocol.IrisProtocol;
|
import art.arcane.iris.spi.protocol.IrisProtocol;
|
||||||
|
import art.arcane.iris.spi.protocol.ProtocolException;
|
||||||
|
|
||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
import java.util.Deque;
|
import java.util.Deque;
|
||||||
@@ -14,7 +15,10 @@ import java.util.Set;
|
|||||||
import java.util.function.LongSupplier;
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
public final class IrisClientTileCache {
|
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 long REQUEST_RETRY_MILLIS = 3000L;
|
||||||
private static final int REQUESTS_PER_SECOND = IrisProtocol.MAX_VISION_TILE_REQUESTS_PER_SECOND;
|
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 Map<IrisTileKey, Long> pending;
|
||||||
private final Deque<IrisTileKey> queue;
|
private final Deque<IrisTileKey> queue;
|
||||||
private final Set<IrisTileKey> queued;
|
private final Set<IrisTileKey> queued;
|
||||||
|
private int capacity;
|
||||||
private long windowStartMillis;
|
private long windowStartMillis;
|
||||||
private int sentInWindow;
|
private int sentInWindow;
|
||||||
|
private long droppedMalformed;
|
||||||
|
|
||||||
public IrisClientTileCache(ClientPacketSink sink, LongSupplier clock) {
|
public IrisClientTileCache(ClientPacketSink sink, LongSupplier clock) {
|
||||||
this.sink = sink;
|
this.sink = sink;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
this.assembler = new IrisTileAssembler();
|
this.assembler = new IrisTileAssembler();
|
||||||
|
this.capacity = DEFAULT_MAX_CACHED_TILES;
|
||||||
this.cache = new LinkedHashMap<>(64, 0.75f, true) {
|
this.cache = new LinkedHashMap<>(64, 0.75f, true) {
|
||||||
@Override
|
@Override
|
||||||
protected boolean removeEldestEntry(Map.Entry<IrisTileKey, IrisTileImage> eldest) {
|
protected boolean removeEldestEntry(Map.Entry<IrisTileKey, IrisTileImage> eldest) {
|
||||||
return size() > MAX_CACHED_TILES;
|
return size() > capacity;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this.pending = new HashMap<>();
|
this.pending = new HashMap<>();
|
||||||
@@ -43,10 +50,25 @@ public final class IrisClientTileCache {
|
|||||||
this.queued = new HashSet<>();
|
this.queued = new HashSet<>();
|
||||||
this.windowStartMillis = 0L;
|
this.windowStartMillis = 0L;
|
||||||
this.sentInWindow = 0;
|
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) {
|
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) {
|
if (image == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -60,6 +82,10 @@ public final class IrisClientTileCache {
|
|||||||
return cache.get(key);
|
return cache.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public synchronized long droppedMalformedCount() {
|
||||||
|
return droppedMalformed;
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized void resetRequestQueue() {
|
public synchronized void resetRequestQueue() {
|
||||||
queue.clear();
|
queue.clear();
|
||||||
queued.clear();
|
queued.clear();
|
||||||
@@ -84,6 +110,9 @@ public final class IrisClientTileCache {
|
|||||||
if (now - windowStartMillis >= 1000L) {
|
if (now - windowStartMillis >= 1000L) {
|
||||||
windowStartMillis = now;
|
windowStartMillis = now;
|
||||||
sentInWindow = 0;
|
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()) {
|
while (sentInWindow < REQUESTS_PER_SECOND && !queue.isEmpty()) {
|
||||||
IrisTileKey key = queue.pollFirst();
|
IrisTileKey key = queue.pollFirst();
|
||||||
@@ -107,6 +136,7 @@ public final class IrisClientTileCache {
|
|||||||
pending.clear();
|
pending.clear();
|
||||||
queue.clear();
|
queue.clear();
|
||||||
queued.clear();
|
queued.clear();
|
||||||
|
capacity = DEFAULT_MAX_CACHED_TILES;
|
||||||
sentInWindow = 0;
|
sentInWindow = 0;
|
||||||
windowStartMillis = 0L;
|
windowStartMillis = 0L;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import net.minecraft.client.Minecraft;
|
|||||||
import net.minecraft.client.gui.Font;
|
import net.minecraft.client.gui.Font;
|
||||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
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 {
|
public final class IrisPregenHud {
|
||||||
private static final int PANEL_COLOR = 0xC0101010;
|
private static final int PANEL_COLOR = 0xC0101010;
|
||||||
private static final int TITLE_COLOR = 0xFF66BB6A;
|
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_BACK_COLOR = 0xFF2B2B2B;
|
||||||
private static final int BAR_RUNNING_COLOR = 0xFF66BB6A;
|
private static final int BAR_RUNNING_COLOR = 0xFF66BB6A;
|
||||||
private static final int PAUSED_COLOR = 0xFFFFD54F;
|
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 GRID_BACK_COLOR = 0xFF161616;
|
||||||
private static final int CELL_PENDING_COLOR = 0xFF3A3A3A;
|
private static final int CELL_PENDING_COLOR = 0xFF3A3A3A;
|
||||||
private static final int CELL_GENERATING_COLOR = 0xFFFFD54F;
|
private static final int CELL_GENERATING_COLOR = 0xFFFFD54F;
|
||||||
@@ -38,8 +43,9 @@ public final class IrisPregenHud {
|
|||||||
if (!IrisClient.hudVisible()) {
|
if (!IrisClient.hudVisible()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
IrisMessage.PregenProgress progress = IrisClient.pregen().active();
|
IrisClientPregenState pregen = IrisClient.pregen();
|
||||||
if (progress == null) {
|
IrisMessage.PregenProgress progress = pregen.active();
|
||||||
|
if (progress == null || pregen.activeExpired()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Minecraft minecraft = Minecraft.getInstance();
|
Minecraft minecraft = Minecraft.getInstance();
|
||||||
@@ -47,6 +53,7 @@ public final class IrisPregenHud {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Font font = minecraft.font;
|
Font font = minecraft.font;
|
||||||
|
boolean stale = pregen.activeStale();
|
||||||
boolean paused = progress.state() == IrisMessage.PregenProgress.STATE_PAUSED;
|
boolean paused = progress.state() == IrisMessage.PregenProgress.STATE_PAUSED;
|
||||||
double percent = progress.chunksTotal() > 0L
|
double percent = progress.chunksTotal() > 0L
|
||||||
? clampPercent((double) progress.chunksDone() / (double) progress.chunksTotal() * 100.0D)
|
? 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("total", String.format("%,d", progress.chunksTotal())),
|
||||||
MessageArgument.trusted("percent", String.format("%.1f", percent))
|
MessageArgument.trusted("percent", String.format("%.1f", percent))
|
||||||
);
|
);
|
||||||
String tail = paused ? IrisLanguage.plain(ClientUiMessages.PREGEN_PAUSED) : rateAndEta(progress);
|
String tail = tail(progress, pregen, stale, paused);
|
||||||
int accent = paused ? PAUSED_COLOR : BAR_RUNNING_COLOR;
|
int accent = stale ? STALE_COLOR : paused ? PAUSED_COLOR : BAR_RUNNING_COLOR;
|
||||||
|
|
||||||
int lineHeight = font.lineHeight;
|
int lineHeight = font.lineHeight;
|
||||||
int contentWidth = Math.max(MIN_WIDTH, Math.max(font.width(title), Math.max(font.width(stats), font.width(tail))));
|
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);
|
graphics.fill(ORIGIN_X - PADDING, ORIGIN_Y - PADDING, ORIGIN_X + panelWidth + PADDING, ORIGIN_Y + panelHeight + PADDING, PANEL_COLOR);
|
||||||
|
|
||||||
int cursorY = ORIGIN_Y;
|
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;
|
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;
|
cursorY += lineHeight + ROW_GAP;
|
||||||
|
|
||||||
int fillWidth = (int) Math.round(contentWidth * (percent / 100.0D));
|
int fillWidth = (int) Math.round(contentWidth * (percent / 100.0D));
|
||||||
@@ -96,7 +103,7 @@ public final class IrisPregenHud {
|
|||||||
}
|
}
|
||||||
cursorY += BAR_HEIGHT + ROW_GAP;
|
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) {
|
if (showMap) {
|
||||||
renderMinimap(graphics, regionMap, bounds, cellPx, gridWidth, gridHeight, ORIGIN_Y + contentHeight + MINIMAP_GAP);
|
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) {
|
private static String rateAndEta(IrisMessage.PregenProgress progress) {
|
||||||
String rate = String.format("%,.0f", progress.chunksPerSecond());
|
String rate = String.format("%,.0f", progress.chunksPerSecond());
|
||||||
if (progress.etaMillis() > 0L) {
|
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.IrisMessage;
|
||||||
import art.arcane.iris.spi.protocol.IrisProtocol;
|
import art.arcane.iris.spi.protocol.IrisProtocol;
|
||||||
|
import art.arcane.iris.spi.protocol.ProtocolException;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
final class IrisTileAssembler {
|
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)
|
(IrisTileCodec.MAX_DECODED_BYTES + IrisProtocol.VISION_TILE_MAX_CHUNK_BYTES - 1)
|
||||||
/ 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;
|
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 chunkCount = tile.chunkCount();
|
||||||
int chunkIndex = tile.chunkIndex();
|
int chunkIndex = tile.chunkIndex();
|
||||||
if (chunkCount <= 0 || chunkCount > MAX_CHUNK_COUNT || chunkIndex < 0 || chunkIndex >= chunkCount || tile.data() == null) {
|
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());
|
IrisTileKey key = new IrisTileKey(tile.tileX(), tile.tileZ(), tile.zoomLevel());
|
||||||
Partial partial = partials.get(key);
|
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() || partial.chunkCount() != chunkCount) {
|
||||||
if (partial != null && tile.sequence() < partial.sequence()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
partial = new Partial(tile.sequence(), chunkCount);
|
partial = new Partial(tile.sequence(), chunkCount);
|
||||||
partials.put(key, partial);
|
partials.put(key, partial);
|
||||||
} else if (tile.sequence() < partial.sequence()) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
if (!partial.accept(chunkIndex, tile.data())) {
|
if (!partial.accept(chunkIndex, tile.data())) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package art.arcane.iris.client;
|
package art.arcane.iris.client;
|
||||||
|
|
||||||
|
import art.arcane.iris.spi.protocol.ProtocolException;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.DataInputStream;
|
import java.io.DataInputStream;
|
||||||
|
import java.io.EOFException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.zip.DataFormatException;
|
import java.util.zip.DataFormatException;
|
||||||
import java.util.zip.Inflater;
|
import java.util.zip.Inflater;
|
||||||
@@ -17,36 +20,43 @@ public final class IrisTileCodec {
|
|||||||
private 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) {
|
if (deflatedBlob == null || deflatedBlob.length == 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
byte[] raw = inflate(deflatedBlob);
|
byte[] raw = inflate(deflatedBlob);
|
||||||
if (raw == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(raw))) {
|
try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(raw))) {
|
||||||
int width = in.readInt();
|
int width = in.readInt();
|
||||||
int height = in.readInt();
|
int height = in.readInt();
|
||||||
if (width <= 0 || height <= 0 || width > MAX_DIMENSION || height > MAX_DIMENSION) {
|
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 mode = in.readUnsignedByte();
|
||||||
int[] argb = new int[width * height];
|
int[] argb = new int[width * height];
|
||||||
return switch (mode) {
|
return switch (mode) {
|
||||||
case MODE_PALETTE -> decodePalette(in, width, height, argb);
|
case MODE_PALETTE -> decodePalette(in, width, height, argb);
|
||||||
case MODE_RAW_RGB -> decodeRaw(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) {
|
} 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();
|
int paletteSize = in.readInt();
|
||||||
if (paletteSize <= 0 || paletteSize > 256) {
|
if (paletteSize <= 0 || paletteSize > 256) {
|
||||||
return null;
|
throw new ProtocolException("vision tile palette size " + paletteSize + " out of range");
|
||||||
}
|
}
|
||||||
int[] palette = new int[paletteSize];
|
int[] palette = new int[paletteSize];
|
||||||
for (int index = 0; index < paletteSize; index++) {
|
for (int index = 0; index < paletteSize; index++) {
|
||||||
@@ -58,7 +68,7 @@ public final class IrisTileCodec {
|
|||||||
for (int pixel = 0; pixel < argb.length; pixel++) {
|
for (int pixel = 0; pixel < argb.length; pixel++) {
|
||||||
int paletteIndex = in.readUnsignedByte();
|
int paletteIndex = in.readUnsignedByte();
|
||||||
if (paletteIndex >= paletteSize) {
|
if (paletteIndex >= paletteSize) {
|
||||||
return null;
|
throw new ProtocolException("vision tile palette index " + paletteIndex + " beyond size " + paletteSize);
|
||||||
}
|
}
|
||||||
argb[pixel] = palette[paletteIndex];
|
argb[pixel] = palette[paletteIndex];
|
||||||
}
|
}
|
||||||
@@ -75,7 +85,7 @@ public final class IrisTileCodec {
|
|||||||
return new IrisTileImage(width, height, argb);
|
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 inflater = new Inflater();
|
||||||
inflater.setInput(input);
|
inflater.setInput(input);
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(64, input.length * 2));
|
ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(64, input.length * 2));
|
||||||
@@ -84,17 +94,23 @@ public final class IrisTileCodec {
|
|||||||
while (!inflater.finished()) {
|
while (!inflater.finished()) {
|
||||||
int produced = inflater.inflate(buffer);
|
int produced = inflater.inflate(buffer);
|
||||||
if (produced == 0) {
|
if (produced == 0) {
|
||||||
if (inflater.finished() || inflater.needsInput() || inflater.needsDictionary()) {
|
if (inflater.finished()) {
|
||||||
break;
|
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) {
|
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);
|
out.write(buffer, 0, produced);
|
||||||
}
|
}
|
||||||
} catch (DataFormatException malformed) {
|
} catch (DataFormatException malformed) {
|
||||||
return null;
|
throw new ProtocolException("vision tile stream malformed: " + malformed.getMessage());
|
||||||
} finally {
|
} finally {
|
||||||
inflater.end();
|
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.client.gui.components.toasts.ToastManager;
|
||||||
import net.minecraft.network.chat.Component;
|
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 {
|
public final class IrisToastPresenter {
|
||||||
private IrisToastPresenter() {
|
private IrisToastPresenter() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,21 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
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 {
|
public final class IrisVisionScreen extends Screen {
|
||||||
private static final int TILE_PIXELS = 128;
|
private static final int TILE_PIXELS = 128;
|
||||||
private static final int MIN_ZOOM = 0;
|
private static final int MIN_ZOOM = 0;
|
||||||
private static final int MAX_ZOOM = 8;
|
private static final int MAX_ZOOM = 8;
|
||||||
private static final int DEFAULT_ZOOM = 2;
|
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 BACKGROUND_COLOR = 0xF00B0E14;
|
||||||
private static final int PLACEHOLDER_COLOR = 0xFF161A22;
|
private static final int PLACEHOLDER_COLOR = 0xFF161A22;
|
||||||
private static final int GRID_COLOR = 0x33FFFFFF;
|
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);
|
graphics.fill(0, 0, width, height, BACKGROUND_COLOR);
|
||||||
IrisClientSession session = IrisClient.session();
|
IrisClientSession session = IrisClient.session();
|
||||||
if (!session.isReady()) {
|
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));
|
drawHeader(graphics, IrisLanguage.plain(ClientUiMessages.VISION_TITLE), IrisLanguage.plain(ClientUiMessages.VISION_NOT_CONNECTED));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -91,7 +100,9 @@ public final class IrisVisionScreen extends Screen {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
|
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
|
@Override
|
||||||
@@ -102,6 +113,11 @@ public final class IrisVisionScreen extends Screen {
|
|||||||
return true;
|
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
|
@Override
|
||||||
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
|
public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
|
||||||
if (scrollY == 0.0D) {
|
if (scrollY == 0.0D) {
|
||||||
@@ -136,10 +152,16 @@ public final class IrisVisionScreen extends Screen {
|
|||||||
int centerTileX = Math.floorDiv((int) Math.floor(centerBlockX), tileSpanBlocks);
|
int centerTileX = Math.floorDiv((int) Math.floor(centerBlockX), tileSpanBlocks);
|
||||||
int centerTileZ = Math.floorDiv((int) Math.floor(centerBlockZ), tileSpanBlocks);
|
int centerTileZ = Math.floorDiv((int) Math.floor(centerBlockZ), tileSpanBlocks);
|
||||||
|
|
||||||
|
int visibleTiles = (maxTileX - minTileX + 1) * (maxTileZ - minTileZ + 1);
|
||||||
IrisClientTileCache cache = IrisClient.tiles();
|
IrisClientTileCache cache = IrisClient.tiles();
|
||||||
|
cache.ensureCapacity(visibleTiles);
|
||||||
cache.resetRequestQueue();
|
cache.resetRequestQueue();
|
||||||
List<IrisTileKey> missing = new ArrayList<>();
|
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 tileX = minTileX; tileX <= maxTileX; tileX++) {
|
||||||
for (int tileZ = minTileZ; tileZ <= maxTileZ; tileZ++) {
|
for (int tileZ = minTileZ; tileZ <= maxTileZ; tileZ++) {
|
||||||
int screenX = originX + tileX * TILE_PIXELS;
|
int screenX = originX + tileX * TILE_PIXELS;
|
||||||
@@ -158,7 +180,6 @@ public final class IrisVisionScreen extends Screen {
|
|||||||
|
|
||||||
requestMissing(cache, missing, centerTileX, centerTileZ);
|
requestMissing(cache, missing, centerTileX, centerTileZ);
|
||||||
cache.pump();
|
cache.pump();
|
||||||
evictTextures();
|
|
||||||
|
|
||||||
drawMarkers(graphics, mouseX, mouseY, minTileX, maxTileX, minTileZ, maxTileZ, originX, originY, blocksPerPixel);
|
drawMarkers(graphics, mouseX, mouseY, minTileX, maxTileX, minTileZ, maxTileZ, originX, originY, blocksPerPixel);
|
||||||
drawPlayer(graphics, blocksPerPixel);
|
drawPlayer(graphics, blocksPerPixel);
|
||||||
@@ -246,7 +267,14 @@ public final class IrisVisionScreen extends Screen {
|
|||||||
private Identifier ensureTexture(IrisTileKey key, IrisTileImage image) {
|
private Identifier ensureTexture(IrisTileKey key, IrisTileImage image) {
|
||||||
TileTexture existing = textures.get(key);
|
TileTexture existing = textures.get(key);
|
||||||
if (existing != null) {
|
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 tileWidth = image.width();
|
||||||
int tileHeight = image.height();
|
int tileHeight = image.height();
|
||||||
@@ -261,17 +289,26 @@ public final class IrisVisionScreen extends Screen {
|
|||||||
DynamicTexture texture = new DynamicTexture(() -> "iris_vision_tile", nativeImage);
|
DynamicTexture texture = new DynamicTexture(() -> "iris_vision_tile", nativeImage);
|
||||||
Identifier id = Identifier.fromNamespaceAndPath("irisworldgen", texturePath(key));
|
Identifier id = Identifier.fromNamespaceAndPath("irisworldgen", texturePath(key));
|
||||||
Minecraft.getInstance().getTextureManager().register(id, texture);
|
Minecraft.getInstance().getTextureManager().register(id, texture);
|
||||||
textures.put(key, new TileTexture(id, texture));
|
textures.put(key, new TileTexture(id, image));
|
||||||
return id;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
TextureManager manager = Minecraft.getInstance().getTextureManager();
|
TextureManager manager = Minecraft.getInstance().getTextureManager();
|
||||||
Iterator<Map.Entry<IrisTileKey, TileTexture>> iterator = textures.entrySet().iterator();
|
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();
|
Map.Entry<IrisTileKey, TileTexture> entry = iterator.next();
|
||||||
manager.release(entry.getValue().id());
|
manager.release(entry.getValue().id());
|
||||||
iterator.remove();
|
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) {
|
private static long distanceSquared(IrisTileKey key, int centerTileX, int centerTileZ) {
|
||||||
long deltaX = (long) key.tileX() - centerTileX;
|
long deltaX = (long) key.tileX() - centerTileX;
|
||||||
long deltaZ = (long) key.tileZ() - centerTileZ;
|
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.ArrayList;
|
||||||
import java.util.List;
|
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 {
|
public final class IrisWhatOverlay {
|
||||||
private static final int PANEL_COLOR = 0xC0101010;
|
private static final int PANEL_COLOR = 0xC0101010;
|
||||||
private static final int TITLE_COLOR = 0xFF66BB6A;
|
private static final int TITLE_COLOR = 0xFF66BB6A;
|
||||||
|
|||||||
+37
-1
@@ -1,14 +1,20 @@
|
|||||||
package art.arcane.iris.client.mixin;
|
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.IrisModdedChunkGenerator;
|
||||||
|
import art.arcane.iris.modded.ModdedMixinFlags;
|
||||||
import com.mojang.serialization.Lifecycle;
|
import com.mojang.serialization.Lifecycle;
|
||||||
import net.minecraft.client.Minecraft;
|
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.CreateWorldScreen;
|
||||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||||
import net.minecraft.client.gui.screens.worldselection.WorldOpenFlows;
|
import net.minecraft.client.gui.screens.worldselection.WorldOpenFlows;
|
||||||
import net.minecraft.core.Holder;
|
import net.minecraft.core.Holder;
|
||||||
import net.minecraft.core.Registry;
|
import net.minecraft.core.Registry;
|
||||||
import net.minecraft.core.registries.Registries;
|
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.resources.ResourceKey;
|
||||||
import net.minecraft.server.WorldStem;
|
import net.minecraft.server.WorldStem;
|
||||||
import net.minecraft.server.packs.repository.PackRepository;
|
import net.minecraft.server.packs.repository.PackRepository;
|
||||||
@@ -23,6 +29,12 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
|||||||
|
|
||||||
import java.util.Optional;
|
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)
|
@Mixin(WorldOpenFlows.class)
|
||||||
public abstract class IrisWorldOpenFlowsMixin {
|
public abstract class IrisWorldOpenFlowsMixin {
|
||||||
@Invoker("openWorldLoadBundledResourcePack")
|
@Invoker("openWorldLoadBundledResourcePack")
|
||||||
@@ -40,13 +52,36 @@ public abstract class IrisWorldOpenFlowsMixin {
|
|||||||
Runnable task,
|
Runnable task,
|
||||||
boolean skipWarning,
|
boolean skipWarning,
|
||||||
CallbackInfo info) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
task.run();
|
task.run();
|
||||||
info.cancel();
|
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)
|
@Inject(method = "openWorldCheckWorldStemCompatibility", at = @At("HEAD"), cancellable = true)
|
||||||
private void iris$openWorldCheckWorldStemCompatibility(
|
private void iris$openWorldCheckWorldStemCompatibility(
|
||||||
LevelStorageSource.LevelStorageAccess worldAccess,
|
LevelStorageSource.LevelStorageAccess worldAccess,
|
||||||
@@ -54,6 +89,7 @@ public abstract class IrisWorldOpenFlowsMixin {
|
|||||||
PackRepository packRepository,
|
PackRepository packRepository,
|
||||||
Runnable onCancel,
|
Runnable onCancel,
|
||||||
CallbackInfo info) {
|
CallbackInfo info) {
|
||||||
|
ModdedMixinFlags.markWorldOpenFlows();
|
||||||
if (!iris$containsIrisGenerator(worldStem)) {
|
if (!iris$containsIrisGenerator(worldStem)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -1,5 +1,6 @@
|
|||||||
package art.arcane.iris.client.mixin;
|
package art.arcane.iris.client.mixin;
|
||||||
|
|
||||||
|
import art.arcane.iris.modded.ModdedMixinFlags;
|
||||||
import art.arcane.iris.modded.ModdedWorldgenIds;
|
import art.arcane.iris.modded.ModdedWorldgenIds;
|
||||||
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState;
|
||||||
import net.minecraft.core.Holder;
|
import net.minecraft.core.Holder;
|
||||||
@@ -15,6 +16,10 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
|||||||
|
|
||||||
import java.util.Optional;
|
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)
|
@Mixin(WorldCreationUiState.WorldTypeEntry.class)
|
||||||
public class IrisWorldTypeEntryMixin {
|
public class IrisWorldTypeEntryMixin {
|
||||||
@Shadow
|
@Shadow
|
||||||
@@ -23,6 +28,7 @@ public class IrisWorldTypeEntryMixin {
|
|||||||
|
|
||||||
@Inject(method = "describePreset", at = @At("HEAD"), cancellable = true)
|
@Inject(method = "describePreset", at = @At("HEAD"), cancellable = true)
|
||||||
private void iris$describePreset(CallbackInfoReturnable<Component> info) {
|
private void iris$describePreset(CallbackInfoReturnable<Component> info) {
|
||||||
|
ModdedMixinFlags.markWorldTypeEntry();
|
||||||
Optional<ResourceKey<WorldPreset>> key = preset == null
|
Optional<ResourceKey<WorldPreset>> key = preset == null
|
||||||
? Optional.empty()
|
? Optional.empty()
|
||||||
: preset.unwrapKey();
|
: preset.unwrapKey();
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ dependencies {
|
|||||||
minecraft("com.mojang:minecraft:${minecraftVersion}")
|
minecraft("com.mojang:minecraft:${minecraftVersion}")
|
||||||
implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}")
|
implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}")
|
||||||
testImplementation('junit:junit:4.13.2')
|
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 = [
|
List<Object> fabricApi = [
|
||||||
libs.fabricApi.base,
|
libs.fabricApi.base,
|
||||||
libs.fabricApi.registrySync,
|
libs.fabricApi.registrySync,
|
||||||
@@ -180,6 +188,7 @@ loom {
|
|||||||
vmArg('-Xmx8G')
|
vmArg('-Xmx8G')
|
||||||
}
|
}
|
||||||
server {
|
server {
|
||||||
|
runDir(providers.gradleProperty('irisServerRunDir').getOrElse('run'))
|
||||||
String parity = providers.gradleProperty('irisParity').getOrNull()
|
String parity = providers.gradleProperty('irisParity').getOrNull()
|
||||||
if (parity != null) {
|
if (parity != null) {
|
||||||
property('iris.parity', parity)
|
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 {
|
tasks.named('shadowJar', ShadowJar).configure {
|
||||||
doFirst {
|
doFirst {
|
||||||
delete(fileTree(layout.buildDirectory.dir('libs')) {
|
delete(fileTree(layout.buildDirectory.dir('libs')) {
|
||||||
include('Iris v* [Fabric] *.jar')
|
include('Iris v* [Fabric] *.jar')
|
||||||
|
include('iris-fabric-*.jar')
|
||||||
})
|
})
|
||||||
delete(layout.buildDirectory.file("libs/Iris-${project.version}+mc${minecraftVersion}-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/*.SF')
|
||||||
exclude('META-INF/*.DSA')
|
exclude('META-INF/*.DSA')
|
||||||
exclude('META-INF/*.RSA')
|
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
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm')
|
relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm')
|
||||||
relocate('io.sentry', 'art.arcane.iris.shadow.sentry')
|
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')) {
|
from(project.configurations.named('jij')) {
|
||||||
into('META-INF/jars')
|
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 {
|
tasks.named('assemble').configure {
|
||||||
dependsOn(tasks.named('shadowJar'))
|
dependsOn(tasks.named('shadowJar'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
[23:26:54] [Test worker/INFO]: Iris registered custom content provider 'iris_deferred_test'
|
[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
|
||||||
[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
|
[18:39:39] [Test worker/WARN]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
|
||||||
java.lang.IllegalArgumentException: entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial9922444042409438775/iris-dimensions.json has no dimension
|
[18:39:39] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:definitely_not_a_real_block
|
||||||
at art.arcane.iris.modded.ModdedDimensionRegistryStore.required(ModdedDimensionRegistryStore.java:117)
|
[18:39:39] [Test worker/INFO]: [STDERR]: [Iris/WARN] Can't find block data for minecraft:minecraft:definitely_not_a_real_block
|
||||||
at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:68)
|
[18:39:39] [Test worker/INFO]: [STDERR]: [Iris/WARN] Block 'minecraft:oak_log' rejected state 'not_a_property=x'; using its default state
|
||||||
at art.arcane.iris.modded.ModdedDimensionRegistryStoreTest.malformedEntryDoesNotDiscardHealthyEntries(ModdedDimensionRegistryStoreTest.java:51)
|
[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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
|
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.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 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.run(GradleWorkerMain.java:69)
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
|
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
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
|
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.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 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.run(GradleWorkerMain.java:69)
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
|
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
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
|
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.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 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.run(GradleWorkerMain.java:69)
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
|
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
|
java.lang.RuntimeException: cleanup failed
|
||||||
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32)
|
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32)
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
|
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 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.run(GradleWorkerMain.java:69)
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
|
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
|
java.lang.RuntimeException: enable failed
|
||||||
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31)
|
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31)
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
|
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
|
Suppressed: java.lang.RuntimeException: cleanup failed
|
||||||
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32)
|
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32)
|
||||||
... 42 more
|
... 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
|
java.lang.IllegalStateException: stop request failed
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:235)
|
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238)
|
||||||
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:179)
|
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:142)
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:232)
|
at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:235)
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
|
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
|
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.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 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.run(GradleWorkerMain.java:69)
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
|
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
|
java.lang.IllegalStateException: shutdown wait failed
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:261)
|
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264)
|
||||||
at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:194)
|
at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:157)
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:259)
|
at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:262)
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
|
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
|
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.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 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.run(GradleWorkerMain.java:69)
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
|
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
|
java.lang.IllegalStateException: check failed
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:221)
|
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224)
|
||||||
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:174)
|
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:137)
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:219)
|
at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:222)
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
|
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
|
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.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 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.run(GradleWorkerMain.java:69)
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
|
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'
|
[18:39:39] [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/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
|
java.lang.RuntimeException: provider init failed
|
||||||
at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39)
|
at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39)
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
|
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 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.run(GradleWorkerMain.java:69)
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
|
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)
|
[18:39:39] [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]: [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;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
public final class FabricModdedLoader implements ModdedLoader {
|
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
|
@Override
|
||||||
public String platformName() {
|
public String platformName() {
|
||||||
@@ -67,6 +67,8 @@ public final class FabricModdedLoader implements ModdedLoader {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void invalidateLevelCache(MinecraftServer server) {
|
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
|
@Override
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public final class IrisFabricClient implements ClientModInitializer {
|
|||||||
HudElementRegistry.addLast(IrisClient.HUD_ELEMENT_ID, (graphics, delta) -> IrisClientHud.render(graphics));
|
HudElementRegistry.addLast(IrisClient.HUD_ELEMENT_ID, (graphics, delta) -> IrisClientHud.render(graphics));
|
||||||
ClientTickEvents.END_CLIENT_TICK.register(client -> {
|
ClientTickEvents.END_CLIENT_TICK.register(client -> {
|
||||||
IrisClient.tick();
|
IrisClient.tick();
|
||||||
|
IrisClientHud.tick();
|
||||||
IrisClientKeybinds.pollToggle();
|
IrisClientKeybinds.pollToggle();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,14 @@
|
|||||||
"name": "Iris",
|
"name": "Iris",
|
||||||
"description": "Iris World Generation Engine (Fabric adapter - native chunk generator, explicit Iris world datapack workflow, engine lifecycle)",
|
"description": "Iris World Generation Engine (Fabric adapter - native chunk generator, explicit Iris world datapack workflow, engine lifecycle)",
|
||||||
"authors": ["Arcane Arts (Volmit Software)"],
|
"authors": ["Arcane Arts (Volmit Software)"],
|
||||||
|
"contributors": ["cyberpwn", "NextdoorPsycho", "Vatuu"],
|
||||||
"contact": {
|
"contact": {
|
||||||
|
"homepage": "https://docs.volmit.com/iris/",
|
||||||
|
"issues": "https://github.com/VolmitSoftware/Iris/issues",
|
||||||
"sources": "https://github.com/VolmitSoftware/Iris"
|
"sources": "https://github.com/VolmitSoftware/Iris"
|
||||||
},
|
},
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
|
"icon": "assets/irisworldgen/icon.png",
|
||||||
"environment": "*",
|
"environment": "*",
|
||||||
"accessWidener": "irisworldgen.accesswidener",
|
"accessWidener": "irisworldgen.accesswidener",
|
||||||
"mixins": [
|
"mixins": [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"required": true,
|
"required": true,
|
||||||
"minVersion": "0.8",
|
"minVersion": "0.8",
|
||||||
"package": "art.arcane.iris.fabric.mixin",
|
"package": "art.arcane.iris.fabric.mixin",
|
||||||
"compatibilityLevel": "JAVA_25",
|
"compatibilityLevel": "JAVA_21",
|
||||||
"mixins": [
|
"mixins": [
|
||||||
"BlockItemMixin",
|
"BlockItemMixin",
|
||||||
"BlockMixin",
|
"BlockMixin",
|
||||||
|
|||||||
@@ -225,10 +225,14 @@ tasks.named('shadowJar', ShadowJar).configure {
|
|||||||
doFirst {
|
doFirst {
|
||||||
delete(fileTree(layout.buildDirectory.dir('libs')) {
|
delete(fileTree(layout.buildDirectory.dir('libs')) {
|
||||||
include('Iris v* [Forge] *.jar')
|
include('Iris v* [Forge] *.jar')
|
||||||
|
include('iris-forge-*.jar')
|
||||||
})
|
})
|
||||||
delete(layout.buildDirectory.file("libs/Iris-${project.version}+mc${minecraftVersion}-forge.jar"))
|
delete(layout.buildDirectory.file("libs/Iris-${project.version}+mc${minecraftVersion}-forge.jar"))
|
||||||
}
|
}
|
||||||
manifest {
|
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')
|
attributes('MixinConfigs': 'irisworldgen.entity.mixins.json,irisworldgen.client.mixins.json')
|
||||||
}
|
}
|
||||||
archiveFileName.set(irisArtifactName('Forge', "${minecraftVersion}+${loaderDisplayVersion(forgeVersion)}"))
|
archiveFileName.set(irisArtifactName('Forge', "${minecraftVersion}+${loaderDisplayVersion(forgeVersion)}"))
|
||||||
@@ -240,16 +244,43 @@ tasks.named('shadowJar', ShadowJar).configure {
|
|||||||
exclude('META-INF/*.SF')
|
exclude('META-INF/*.SF')
|
||||||
exclude('META-INF/*.DSA')
|
exclude('META-INF/*.DSA')
|
||||||
exclude('META-INF/*.RSA')
|
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/**')
|
exclude('org/apache/commons/lang/enum/**')
|
||||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
// Compile-only annotation carriers. Shipping them duplicates packages other mods also ship.
|
||||||
relocate('org.objectweb.asm', 'art.arcane.iris.shadow.asm')
|
|
||||||
relocate('io.sentry', 'art.arcane.iris.shadow.sentry')
|
|
||||||
exclude('org/jspecify/**')
|
exclude('org/jspecify/**')
|
||||||
exclude('com/google/errorprone/**')
|
exclude('com/google/errorprone/**')
|
||||||
exclude('com/google/j2objc/**')
|
exclude('com/google/j2objc/**')
|
||||||
exclude('org/checkerframework/**')
|
exclude('org/checkerframework/**')
|
||||||
exclude('org/jetbrains/annotations/**')
|
exclude('org/jetbrains/annotations/**')
|
||||||
exclude('org/intellij/lang/**')
|
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 {
|
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
|
[01Aug2026 18:39:44.767] [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
|
[01Aug2026 18:39:44.769] [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
|
[01Aug2026 18:39:44.769] [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'
|
[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
|
||||||
[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
|
[01Aug2026 18:39:47.192] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
|
||||||
java.lang.IllegalArgumentException: entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial3400051221726996941/iris-dimensions.json has no dimension
|
[01Aug2026 18:39:47.231] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test'
|
||||||
at art.arcane.iris.modded.ModdedDimensionRegistryStore.required(ModdedDimensionRegistryStore.java:117) ~[main/:?]
|
[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"}
|
||||||
at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:68) ~[main/:?]
|
[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
|
||||||
at art.arcane.iris.modded.ModdedDimensionRegistryStoreTest.malformedEntryDoesNotDiscardHealthyEntries(ModdedDimensionRegistryStoreTest.java:51) ~[test/:?]
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?]
|
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.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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?]
|
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.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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?]
|
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.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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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
|
java.lang.RuntimeException: cleanup failed
|
||||||
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
|
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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
|
java.lang.RuntimeException: enable failed
|
||||||
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?]
|
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?]
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
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
|
Suppressed: java.lang.RuntimeException: cleanup failed
|
||||||
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
|
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
|
||||||
... 42 more
|
... 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
|
java.lang.IllegalStateException: stop request failed
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:235) ~[test/:?]
|
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238) ~[test/:?]
|
||||||
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:179) ~[main/:?]
|
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:142) ~[main/:?]
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:232) ~[test/:?]
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?]
|
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.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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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
|
java.lang.IllegalStateException: shutdown wait failed
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:261) ~[test/:?]
|
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264) ~[test/:?]
|
||||||
at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:194) ~[main/:?]
|
at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:157) ~[main/:?]
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:259) ~[test/:?]
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?]
|
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.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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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
|
java.lang.IllegalStateException: check failed
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:221) ~[test/:?]
|
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224) ~[test/:?]
|
||||||
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:174) ~[main/:?]
|
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:137) ~[main/:?]
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:219) ~[test/:?]
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?]
|
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.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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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'
|
[01Aug2026 18:39:47.390] [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.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
|
java.lang.RuntimeException: provider init failed
|
||||||
at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?]
|
at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?]
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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'
|
[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
|
||||||
[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
|
[01Aug2026 18:39:47.192] [Test worker/WARN] [Iris/]: Iris cannot enumerate the biome registry before the Minecraft server is available; returning no biomes
|
||||||
java.lang.IllegalArgumentException: entry 1 in /var/folders/l1/g6lr3c5x26z_mdmtfzt9gjbr0000gn/T/iris-dimension-registry-partial3400051221726996941/iris-dimensions.json has no dimension
|
[01Aug2026 18:39:47.231] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test'
|
||||||
at art.arcane.iris.modded.ModdedDimensionRegistryStore.required(ModdedDimensionRegistryStore.java:117) ~[main/:?]
|
[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"}
|
||||||
at art.arcane.iris.modded.ModdedDimensionRegistryStore.load(ModdedDimensionRegistryStore.java:68) ~[main/:?]
|
[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
|
||||||
at art.arcane.iris.modded.ModdedDimensionRegistryStoreTest.malformedEntryDoesNotDiscardHealthyEntries(ModdedDimensionRegistryStoreTest.java:51) ~[test/:?]
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?]
|
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.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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?]
|
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.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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?]
|
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.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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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
|
java.lang.RuntimeException: cleanup failed
|
||||||
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
|
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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
|
java.lang.RuntimeException: enable failed
|
||||||
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?]
|
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:31) ~[test/:?]
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
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
|
Suppressed: java.lang.RuntimeException: cleanup failed
|
||||||
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
|
at art.arcane.iris.modded.ModdedServiceManagerTest.rollsBackEnabledServicesAndPreservesCleanupFailures(ModdedServiceManagerTest.java:32) ~[test/:?]
|
||||||
... 42 more
|
... 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
|
java.lang.IllegalStateException: stop request failed
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:235) ~[test/:?]
|
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:238) ~[test/:?]
|
||||||
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:179) ~[main/:?]
|
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:142) ~[main/:?]
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:232) ~[test/:?]
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?]
|
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.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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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
|
java.lang.IllegalStateException: shutdown wait failed
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:261) ~[test/:?]
|
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:264) ~[test/:?]
|
||||||
at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:194) ~[main/:?]
|
at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:157) ~[main/:?]
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:259) ~[test/:?]
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?]
|
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.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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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
|
java.lang.IllegalStateException: check failed
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:221) ~[test/:?]
|
at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:224) ~[test/:?]
|
||||||
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:174) ~[main/:?]
|
at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:137) ~[main/:?]
|
||||||
at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:219) ~[test/:?]
|
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/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
||||||
at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?]
|
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.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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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'
|
[01Aug2026 18:39:47.390] [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.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
|
java.lang.RuntimeException: provider init failed
|
||||||
at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?]
|
at art.arcane.iris.modded.api.ModdedCustomContentRegistryTest.failedDiscoveryPublishesNothingAndPreservesTheCause(ModdedCustomContentRegistryTest.java:39) ~[test/:?]
|
||||||
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?]
|
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 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.run(GradleWorkerMain.java:69) [gradle-worker.jar:?]
|
||||||
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [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 final class ForgeModdedLoader implements ModdedLoader {
|
||||||
public static final PermissionNode<Boolean> TREE_FELLER_PERMISSION = new PermissionNode<>(
|
public static final PermissionNode<Boolean> TREE_FELLER_PERMISSION = new PermissionNode<>(
|
||||||
"iris",
|
"irisworldgen",
|
||||||
"treefeller",
|
"treefeller",
|
||||||
PermissionTypes.BOOLEAN,
|
PermissionTypes.BOOLEAN,
|
||||||
(player, playerId, contexts) ->
|
(player, playerId, contexts) ->
|
||||||
|
|||||||
@@ -75,7 +75,10 @@ public final class ForgeProtocolNetworking {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean canReceive(ServerPlayer player) {
|
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
|
@Override
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ public final class IrisForgeBootstrap {
|
|||||||
|
|
||||||
ForgeProtocolNetworking.register();
|
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) {
|
if (FMLEnvironment.dist == Dist.CLIENT) {
|
||||||
IrisForgeClient.init();
|
IrisForgeClient.init();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,11 @@ public final class IrisForgeClient {
|
|||||||
ClientPlayerNetworkEvent.LoggingIn.BUS.addListener((ClientPlayerNetworkEvent.LoggingIn event) -> IrisClient.onWorldJoin());
|
ClientPlayerNetworkEvent.LoggingIn.BUS.addListener((ClientPlayerNetworkEvent.LoggingIn event) -> IrisClient.onWorldJoin());
|
||||||
ClientPlayerNetworkEvent.LoggingOut.BUS.addListener((ClientPlayerNetworkEvent.LoggingOut event) -> IrisClient.onDisconnect());
|
ClientPlayerNetworkEvent.LoggingOut.BUS.addListener((ClientPlayerNetworkEvent.LoggingOut event) -> IrisClient.onDisconnect());
|
||||||
InputEvent.Key.BUS.addListener((InputEvent.Key event) -> IrisClientKeybinds.pollToggle());
|
InputEvent.Key.BUS.addListener((InputEvent.Key event) -> IrisClientKeybinds.pollToggle());
|
||||||
TickEvent.ClientTickEvent.Post.BUS.addListener(
|
TickEvent.ClientTickEvent.Post.BUS.addListener((TickEvent.ClientTickEvent.Post event) -> {
|
||||||
(TickEvent.ClientTickEvent.Post event) -> IrisClient.tick());
|
IrisClientKeybinds.pollToggle();
|
||||||
|
IrisClient.tick();
|
||||||
|
IrisClientHud.tick();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void sendToServer(byte[] frame) {
|
private static void sendToServer(byte[] frame) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
modLoader = "javafml"
|
modLoader = "javafml"
|
||||||
loaderVersion = "[65,)"
|
loaderVersion = "[65,66)"
|
||||||
license = "GPL-3.0"
|
license = "GPL-3.0"
|
||||||
|
issueTrackerURL = "https://github.com/VolmitSoftware/Iris/issues"
|
||||||
|
|
||||||
[[mods]]
|
[[mods]]
|
||||||
modId = "irisworldgen"
|
modId = "irisworldgen"
|
||||||
@@ -8,11 +9,14 @@ version = "${version}"
|
|||||||
displayName = "Iris"
|
displayName = "Iris"
|
||||||
description = "Iris World Generation Engine (Forge adapter - native chunk generator, explicit Iris world datapack workflow, engine lifecycle)"
|
description = "Iris World Generation Engine (Forge adapter - native chunk generator, explicit Iris world datapack workflow, engine lifecycle)"
|
||||||
authors = "Arcane Arts (Volmit Software)"
|
authors = "Arcane Arts (Volmit Software)"
|
||||||
|
credits = "cyberpwn, NextdoorPsycho, Vatuu"
|
||||||
|
displayURL = "https://docs.volmit.com/iris/"
|
||||||
|
logoFile = "assets/irisworldgen/icon.png"
|
||||||
|
|
||||||
[[dependencies.irisworldgen]]
|
[[dependencies.irisworldgen]]
|
||||||
modId = "forge"
|
modId = "forge"
|
||||||
mandatory = true
|
mandatory = true
|
||||||
versionRange = "[65,)"
|
versionRange = "[65,66)"
|
||||||
ordering = "NONE"
|
ordering = "NONE"
|
||||||
side = "BOTH"
|
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 long maxAgeNanos;
|
||||||
private final LongSupplier nanoTime;
|
private final LongSupplier nanoTime;
|
||||||
private final ArrayDeque<Long> queue;
|
private final ArrayDeque<Long> queue;
|
||||||
|
private final ArrayDeque<Expiry> expiryOrder;
|
||||||
private final Map<Long, Long> pending;
|
private final Map<Long, Long> pending;
|
||||||
private final Set<Long> queued;
|
private final Set<Long> queued;
|
||||||
private boolean closed;
|
private boolean closed;
|
||||||
@@ -52,6 +53,7 @@ final class InitialSpawnQueue {
|
|||||||
this.maxAgeNanos = maxAgeNanos;
|
this.maxAgeNanos = maxAgeNanos;
|
||||||
this.nanoTime = nanoTime;
|
this.nanoTime = nanoTime;
|
||||||
this.queue = new ArrayDeque<>(Math.min(capacity, 256));
|
this.queue = new ArrayDeque<>(Math.min(capacity, 256));
|
||||||
|
this.expiryOrder = new ArrayDeque<>(Math.min(capacity, 256));
|
||||||
this.pending = new HashMap<>();
|
this.pending = new HashMap<>();
|
||||||
this.queued = new HashSet<>();
|
this.queued = new HashSet<>();
|
||||||
}
|
}
|
||||||
@@ -66,9 +68,12 @@ final class InitialSpawnQueue {
|
|||||||
if (pending.size() >= capacity) {
|
if (pending.size() >= capacity) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
pending.put(key, nanoTime.getAsLong());
|
long offeredAt = nanoTime.getAsLong();
|
||||||
queued.add(key);
|
pending.put(key, offeredAt);
|
||||||
queue.addLast(key);
|
expiryOrder.addLast(new Expiry(key, offeredAt));
|
||||||
|
if (queued.add(key)) {
|
||||||
|
queue.addLast(key);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +97,10 @@ final class InitialSpawnQueue {
|
|||||||
pending.remove(key);
|
pending.remove(key);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
expire(now);
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
expire(now);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +122,7 @@ final class InitialSpawnQueue {
|
|||||||
if (queued.remove(key)) {
|
if (queued.remove(key)) {
|
||||||
queue.removeFirstOccurrence(key);
|
queue.removeFirstOccurrence(key);
|
||||||
}
|
}
|
||||||
|
expire(nanoTime.getAsLong());
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized boolean isEmpty() {
|
synchronized boolean isEmpty() {
|
||||||
@@ -127,6 +135,7 @@ final class InitialSpawnQueue {
|
|||||||
|
|
||||||
synchronized void clear() {
|
synchronized void clear() {
|
||||||
queue.clear();
|
queue.clear();
|
||||||
|
expiryOrder.clear();
|
||||||
pending.clear();
|
pending.clear();
|
||||||
queued.clear();
|
queued.clear();
|
||||||
}
|
}
|
||||||
@@ -136,24 +145,41 @@ final class InitialSpawnQueue {
|
|||||||
clear();
|
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) {
|
private void expire(long now) {
|
||||||
Set<Long> expired = new HashSet<>();
|
Expiry head;
|
||||||
for (Map.Entry<Long, Long> entry : pending.entrySet()) {
|
while ((head = expiryOrder.peekFirst()) != null) {
|
||||||
if (expired(entry.getValue(), now)) {
|
Long offeredAt = pending.get(head.key());
|
||||||
expired.add(entry.getKey());
|
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()) {
|
if (queue.size() > capacity) {
|
||||||
return;
|
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) {
|
private boolean expired(long offeredAt, long now) {
|
||||||
return now - offeredAt >= maxAgeNanos;
|
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.Structure;
|
||||||
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.atomic.AtomicReferenceArray;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
final class IrisModdedBiomeSource extends BiomeSource {
|
final class IrisModdedBiomeSource extends BiomeSource {
|
||||||
private static final int BIOME_CACHE_MAX = 262144;
|
|
||||||
private static final int UNRESOLVED_WARN_KEYS_MAX = 256;
|
private static final int UNRESOLVED_WARN_KEYS_MAX = 256;
|
||||||
|
|
||||||
private final BiomeSource serializedSource;
|
private final BiomeSource serializedSource;
|
||||||
private final Set<String> warnedUnresolvedBiomeKeys = ConcurrentHashMap.newKeySet();
|
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();
|
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 IrisModdedChunkGenerator generator;
|
||||||
private volatile Set<String> possibleStructureBiomeKeys;
|
private volatile Set<String> possibleStructureBiomeKeys;
|
||||||
|
private volatile BiomeKeySets biomeKeySets;
|
||||||
|
private volatile PossibleBiomes possibleBiomesCache;
|
||||||
|
|
||||||
IrisModdedBiomeSource(BiomeSource serializedSource) {
|
IrisModdedBiomeSource(BiomeSource serializedSource) {
|
||||||
this.serializedSource = serializedSource;
|
this.serializedSource = serializedSource;
|
||||||
@@ -69,16 +77,31 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void clearCaches() {
|
void clearCaches() {
|
||||||
visibleBiomeCache.clear();
|
// Republish empty tables instead of iterating: a repoint must not walk hundreds of thousands of slots
|
||||||
structureBiomeCache.clear();
|
// on the calling thread, and every reader is a pure function of the key so a lost entry is only a miss.
|
||||||
surfaceStructureBiomeCache.clear();
|
// 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();
|
warnedUnresolvedBiomeKeys.clear();
|
||||||
possibleStructureBiomeKeys = null;
|
possibleStructureBiomeKeys = null;
|
||||||
|
biomeKeySets = null;
|
||||||
|
possibleBiomesCache = null;
|
||||||
for (StructureStateBiomeSource source : structureStateSources) {
|
for (StructureStateBiomeSource source : structureStateSources) {
|
||||||
source.clearCache();
|
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) {
|
BiomeSource forStructureState(HolderLookup<StructureSet> structureSets) {
|
||||||
LinkedHashSet<Holder<Biome>> possible = new LinkedHashSet<>();
|
LinkedHashSet<Holder<Biome>> possible = new LinkedHashSet<>();
|
||||||
Registry<Biome> registry = biomeRegistry();
|
Registry<Biome> registry = biomeRegistry();
|
||||||
@@ -113,12 +136,68 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Stream<Holder<Biome>> collectPossibleBiomes() {
|
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();
|
Registry<Biome> registry = biomeRegistry();
|
||||||
LinkedHashSet<Holder<Biome>> possible = new LinkedHashSet<>();
|
LinkedHashSet<Holder<Biome>> possible = new LinkedHashSet<>();
|
||||||
if (registry == null) {
|
if (registry == null) {
|
||||||
for (Holder<Biome> biome : serializedSource.possibleBiomes()) {
|
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);
|
possible.add(biome);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,18 +208,44 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
|||||||
throw new IllegalStateException("Iris structure biomes are not registered: "
|
throw new IllegalStateException("Iris structure biomes are not registered: "
|
||||||
+ missingBiomeKeys);
|
+ missingBiomeKeys);
|
||||||
}
|
}
|
||||||
|
// Registry-ordered, never a hash-ordered walk: see possibleBiomes().
|
||||||
registry.listElements().forEach((Holder.Reference<Biome> reference) -> {
|
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);
|
possible.add(reference);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
warnUnregisteredVisibleBiomes(registry, visibleBiomeKeys);
|
||||||
}
|
}
|
||||||
if (possible.isEmpty()) {
|
if (possible.isEmpty()) {
|
||||||
String phase = registry == null ? "serialized biome bootstrap" : "biome registry";
|
String phase = registry == null ? "serialized biome bootstrap" : "biome registry";
|
||||||
throw new IllegalStateException("Iris configured structure biomes are absent from the "
|
throw new IllegalStateException("Iris configured structure biomes are absent from the "
|
||||||
+ phase + ": " + generatedBiomeKeys);
|
+ 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
|
@Override
|
||||||
@@ -167,16 +272,14 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
|||||||
return getSurfaceStructureBiome(engine, quartX, quartZ, sampler);
|
return getSurfaceStructureBiome(engine, quartX, quartZ, sampler);
|
||||||
}
|
}
|
||||||
long key = packNoiseKey(quartX, quartY, quartZ);
|
long key = packNoiseKey(quartX, quartY, quartZ);
|
||||||
Holder<Biome> cached = structureBiomeCache.get(key);
|
BiomeHolderTable cache = structureBiomeCache;
|
||||||
|
Holder<Biome> cached = cache.get(key);
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
Holder<Biome> resolved = resolveStructureBiome(engine, quartX, quartY, quartZ, sampler);
|
Holder<Biome> resolved = resolveStructureBiome(engine, quartX, quartY, quartZ, sampler);
|
||||||
Holder<Biome> existing = structureBiomeCache.putIfAbsent(key, resolved);
|
cache.put(key, resolved);
|
||||||
if (structureBiomeCache.size() > BIOME_CACHE_MAX) {
|
return resolved;
|
||||||
structureBiomeCache.clear();
|
|
||||||
}
|
|
||||||
return existing == null ? resolved : existing;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Holder<Biome> getVisibleNoiseBiome(int quartX, int quartY, int quartZ, Climate.Sampler sampler) {
|
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");
|
throw new IllegalStateException("Iris visible biome lookup has no active engine runtime");
|
||||||
}
|
}
|
||||||
long key = packNoiseKey(quartX, quartY, quartZ);
|
long key = packNoiseKey(quartX, quartY, quartZ);
|
||||||
Holder<Biome> cached = visibleBiomeCache.get(key);
|
BiomeHolderTable cache = visibleBiomeCache;
|
||||||
|
Holder<Biome> cached = cache.get(key);
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
Holder<Biome> resolved = resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler);
|
Holder<Biome> resolved = resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler);
|
||||||
Holder<Biome> existing = visibleBiomeCache.putIfAbsent(key, resolved);
|
cache.put(key, resolved);
|
||||||
if (visibleBiomeCache.size() > BIOME_CACHE_MAX) {
|
return resolved;
|
||||||
visibleBiomeCache.clear();
|
|
||||||
}
|
|
||||||
return existing == null ? resolved : existing;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,16 +369,14 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
|||||||
private Holder<Biome> getSurfaceStructureBiome(Engine engine, int quartX, int quartZ,
|
private Holder<Biome> getSurfaceStructureBiome(Engine engine, int quartX, int quartZ,
|
||||||
Climate.Sampler sampler) {
|
Climate.Sampler sampler) {
|
||||||
long key = packColumnKey(quartX, quartZ);
|
long key = packColumnKey(quartX, quartZ);
|
||||||
Holder<Biome> cached = surfaceStructureBiomeCache.get(key);
|
BiomeHolderTable cache = surfaceStructureBiomeCache;
|
||||||
|
Holder<Biome> cached = cache.get(key);
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
Holder<Biome> resolved = resolveSurfaceStructureBiome(engine, quartX, quartZ, sampler);
|
Holder<Biome> resolved = resolveSurfaceStructureBiome(engine, quartX, quartZ, sampler);
|
||||||
Holder<Biome> existing = surfaceStructureBiomeCache.putIfAbsent(key, resolved);
|
cache.put(key, resolved);
|
||||||
if (surfaceStructureBiomeCache.size() > BIOME_CACHE_MAX) {
|
return resolved;
|
||||||
surfaceStructureBiomeCache.clear();
|
|
||||||
}
|
|
||||||
return existing == null ? resolved : existing;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Holder<Biome> resolveSurfaceStructureBiome(Engine engine, int quartX, int quartZ,
|
private Holder<Biome> resolveSurfaceStructureBiome(Engine engine, int quartX, int quartZ,
|
||||||
@@ -500,13 +599,35 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Set<String> exactStructureBiomeKeys() {
|
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;
|
IrisModdedChunkGenerator current = generator;
|
||||||
if (current == null) {
|
if (current == null) {
|
||||||
return Set.of();
|
return new BiomeKeySets(generation, Set.of(), Set.of());
|
||||||
}
|
}
|
||||||
Engine engine = current.structureEngineOrNull();
|
Engine engine = current.structureEngineOrNull();
|
||||||
if (engine == null) {
|
if (engine == null) {
|
||||||
return current.configuredStructureBiomeKeys();
|
return new BiomeKeySets(generation, current.configuredStructureBiomeKeys(), Set.of());
|
||||||
}
|
}
|
||||||
GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_structure_biome_keys");
|
GenerationSessionLease lease = tryAcquireGenerationLease(engine, "modded_structure_biome_keys");
|
||||||
if (lease == null) {
|
if (lease == null) {
|
||||||
@@ -516,20 +637,35 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
|||||||
if (!isReady(engine)) {
|
if (!isReady(engine)) {
|
||||||
throw new IllegalStateException("Iris structure biome key lookup has no active engine runtime");
|
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()) {
|
for (IrisBiome irisBiome : engine.getAllBiomes()) {
|
||||||
String derivative = normalizeKey(irisBiome.getStructureDerivativeKey());
|
String derivative = normalizeKey(irisBiome.getStructureDerivativeKey());
|
||||||
if (derivative != null) {
|
if (derivative != null) {
|
||||||
possible.add(derivative);
|
required.add(derivative);
|
||||||
}
|
}
|
||||||
if (!irisBiome.isCustom()) {
|
if (irisBiome.isCustom()) {
|
||||||
continue;
|
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
|
||||||
|
required.add(ModdedWorldgenIds.biomeRef(engine, customBiome.getId()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) {
|
addVisibleBiomeKey(visible, irisBiome.getDerivativeKey());
|
||||||
possible.add(ModdedWorldgenIds.biomeRef(engine, customBiome.getId()));
|
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) {
|
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 static final class StructureStateBiomeSource extends BiomeSource {
|
||||||
private final IrisModdedBiomeSource delegate;
|
private final IrisModdedBiomeSource delegate;
|
||||||
private final Set<Holder<Biome>> possibleBiomes;
|
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) {
|
private StructureStateBiomeSource(IrisModdedBiomeSource delegate, Set<Holder<Biome>> possibleBiomes) {
|
||||||
this.delegate = delegate;
|
this.delegate = delegate;
|
||||||
@@ -619,7 +835,7 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void clearCache() {
|
private void clearCache() {
|
||||||
resolvedBiomes.clear();
|
resolvedBiomes = new BiomeHolderTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -635,16 +851,14 @@ final class IrisModdedBiomeSource extends BiomeSource {
|
|||||||
@Override
|
@Override
|
||||||
public Holder<Biome> getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) {
|
public Holder<Biome> getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) {
|
||||||
long key = packNoiseKey(x, y, z);
|
long key = packNoiseKey(x, y, z);
|
||||||
Holder<Biome> cached = resolvedBiomes.get(key);
|
BiomeHolderTable cache = resolvedBiomes;
|
||||||
|
Holder<Biome> cached = cache.get(key);
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
Holder<Biome> resolved = delegate.resolveRequiredStructureBiome(x, y, z);
|
Holder<Biome> resolved = delegate.resolveRequiredStructureBiome(x, y, z);
|
||||||
Holder<Biome> existing = resolvedBiomes.putIfAbsent(key, resolved);
|
cache.put(key, resolved);
|
||||||
if (resolvedBiomes.size() > BIOME_CACHE_MAX) {
|
return resolved;
|
||||||
resolvedBiomes.clear();
|
|
||||||
}
|
|
||||||
return existing == null ? resolved : existing;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+124
-8
@@ -21,6 +21,7 @@ package art.arcane.iris.modded;
|
|||||||
import art.arcane.iris.core.loader.IrisData;
|
import art.arcane.iris.core.loader.IrisData;
|
||||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||||
import art.arcane.iris.engine.framework.Engine;
|
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.GenerationSessionException;
|
||||||
import art.arcane.iris.engine.framework.GenerationSessionLease;
|
import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||||
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
|
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
|
||||||
@@ -96,6 +97,11 @@ import java.util.function.IntBinaryOperator;
|
|||||||
|
|
||||||
public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
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(
|
public static final MapCodec<IrisModdedChunkGenerator> CODEC = RecordCodecBuilder.mapCodec((RecordCodecBuilder.Instance<IrisModdedChunkGenerator> instance) -> instance.group(
|
||||||
BiomeSource.CODEC.fieldOf("biome_source").forGetter((IrisModdedChunkGenerator generator) -> generator.serializedBiomeSource),
|
BiomeSource.CODEC.fieldOf("biome_source").forGetter((IrisModdedChunkGenerator generator) -> generator.serializedBiomeSource),
|
||||||
Codec.STRING.fieldOf("dimension").forGetter((IrisModdedChunkGenerator generator) -> generator.dimensionKey)
|
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 ModdedEngineBinding<Engine> engineBinding = new ModdedEngineBinding<>(60L, TimeUnit.SECONDS);
|
||||||
private final ModdedNativeStructureStage nativeStructures = new ModdedNativeStructureStage(this);
|
private final ModdedNativeStructureStage nativeStructures = new ModdedNativeStructureStage(this);
|
||||||
private final ModdedSpawnTableMerger spawnTables = new ModdedSpawnTableMerger(this);
|
private final ModdedSpawnTableMerger spawnTables = new ModdedSpawnTableMerger(this);
|
||||||
|
private final ModdedImportedFeatureStage importedFeatures;
|
||||||
private final AtomicBoolean announced = new AtomicBoolean(false);
|
private final AtomicBoolean announced = new AtomicBoolean(false);
|
||||||
private volatile boolean unloading;
|
private volatile boolean unloading;
|
||||||
private volatile Engine engine;
|
private volatile Engine engine;
|
||||||
@@ -126,13 +133,28 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
|||||||
private volatile long lastChunkGenAt = 0L;
|
private volatile long lastChunkGenAt = 0L;
|
||||||
private volatile Set<String> configuredStructureBiomeKeys;
|
private volatile Set<String> configuredStructureBiomeKeys;
|
||||||
private volatile ModdedDimensionMetadata.ConfiguredPack configuredPack;
|
private volatile ModdedDimensionMetadata.ConfiguredPack configuredPack;
|
||||||
|
private volatile ModdedDimensionMetadata.DimensionMetadata heightMetadata;
|
||||||
|
private volatile ServerLevel boundLevel;
|
||||||
|
|
||||||
public IrisModdedChunkGenerator(BiomeSource biomeSource, String dimensionKey) {
|
public IrisModdedChunkGenerator(BiomeSource biomeSource, String dimensionKey) {
|
||||||
this(biomeSource, dimensionKey, new IrisModdedBiomeSource(biomeSource));
|
this(biomeSource, dimensionKey, new IrisModdedBiomeSource(biomeSource));
|
||||||
}
|
}
|
||||||
|
|
||||||
private IrisModdedChunkGenerator(BiomeSource serializedBiomeSource, String dimensionKey, IrisModdedBiomeSource structureBiomeSource) {
|
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.dimensionKey = dimensionKey;
|
||||||
this.serializedBiomeSource = serializedBiomeSource;
|
this.serializedBiomeSource = serializedBiomeSource;
|
||||||
this.structureBiomeSource = structureBiomeSource;
|
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);
|
throw new IllegalStateException("Iris generator '" + dimensionKey + "' failed to replace its engine", error);
|
||||||
}
|
}
|
||||||
|
this.boundLevel = level;
|
||||||
this.activePack = pack;
|
this.activePack = pack;
|
||||||
this.activeDimensionKey = packDimensionKey;
|
this.activeDimensionKey = packDimensionKey;
|
||||||
this.seedOverride = seed;
|
this.seedOverride = seed;
|
||||||
this.engine = replacement;
|
this.engine = replacement;
|
||||||
this.configuredStructureBiomeKeys = null;
|
this.configuredStructureBiomeKeys = null;
|
||||||
this.configuredPack = null;
|
this.configuredPack = null;
|
||||||
|
this.heightMetadata = engineHeights(replacement);
|
||||||
this.engineBinding.reset();
|
this.engineBinding.reset();
|
||||||
this.engineBinding.complete(replacement);
|
this.engineBinding.complete(replacement);
|
||||||
this.announced.set(false);
|
this.announced.set(false);
|
||||||
this.structureBiomeSource.clearCaches();
|
this.structureBiomeSource.clearCaches();
|
||||||
|
this.importedFeatures.invalidate();
|
||||||
this.nativeStructures.clearWorldCheckStructureShifts();
|
this.nativeStructures.clearWorldCheckStructureShifts();
|
||||||
this.spawnTables.resetVanillaSpawnBiomes();
|
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() {
|
public synchronized void unbindEngine() {
|
||||||
@@ -207,11 +236,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
|||||||
|
|
||||||
private void clearEngineBinding() {
|
private void clearEngineBinding() {
|
||||||
this.engine = null;
|
this.engine = null;
|
||||||
|
this.boundLevel = null;
|
||||||
this.configuredStructureBiomeKeys = null;
|
this.configuredStructureBiomeKeys = null;
|
||||||
this.configuredPack = null;
|
this.configuredPack = null;
|
||||||
this.engineBinding.reset();
|
this.engineBinding.reset();
|
||||||
this.announced.set(false);
|
this.announced.set(false);
|
||||||
this.structureBiomeSource.clearCaches();
|
this.structureBiomeSource.clearCaches();
|
||||||
|
this.importedFeatures.invalidate();
|
||||||
this.nativeStructures.clearWorldCheckStructureShifts();
|
this.nativeStructures.clearWorldCheckStructureShifts();
|
||||||
this.spawnTables.resetVanillaSpawnBiomes();
|
this.spawnTables.resetVanillaSpawnBiomes();
|
||||||
}
|
}
|
||||||
@@ -221,13 +252,16 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
|||||||
this.activeDimensionKey = packDimensionKey;
|
this.activeDimensionKey = packDimensionKey;
|
||||||
this.seedOverride = seed;
|
this.seedOverride = seed;
|
||||||
this.engine = null;
|
this.engine = null;
|
||||||
|
this.boundLevel = null;
|
||||||
this.configuredStructureBiomeKeys = null;
|
this.configuredStructureBiomeKeys = null;
|
||||||
this.configuredPack = null;
|
this.configuredPack = null;
|
||||||
this.engineBinding.reset();
|
this.engineBinding.reset();
|
||||||
this.announced.set(false);
|
this.announced.set(false);
|
||||||
this.structureBiomeSource.clearCaches();
|
this.structureBiomeSource.clearCaches();
|
||||||
|
this.importedFeatures.invalidate();
|
||||||
this.nativeStructures.clearWorldCheckStructureShifts();
|
this.nativeStructures.clearWorldCheckStructureShifts();
|
||||||
this.spawnTables.resetVanillaSpawnBiomes();
|
this.spawnTables.resetVanillaSpawnBiomes();
|
||||||
|
primeHeightMetadata();
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void resetToDefault() {
|
public synchronized void resetToDefault() {
|
||||||
@@ -276,12 +310,18 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ServerLevel boundLevel() {
|
private ServerLevel boundLevel() {
|
||||||
|
ServerLevel cached = boundLevel;
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
MinecraftServer server = ModdedEngineBootstrap.currentServer();
|
MinecraftServer server = ModdedEngineBootstrap.currentServer();
|
||||||
if (server == null) {
|
if (server == null) {
|
||||||
return 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) {
|
if (level.getChunkSource().getGenerator() == this) {
|
||||||
|
boundLevel = level;
|
||||||
return level;
|
return level;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,7 +348,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
|||||||
}
|
}
|
||||||
requireCompletedShutdown(engine);
|
requireCompletedShutdown(engine);
|
||||||
unloading = false;
|
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) {
|
private Engine bindEngine(ServerLevel level) {
|
||||||
@@ -320,6 +364,8 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
|||||||
engineBinding.fail(error);
|
engineBinding.fail(error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
// Cache the owning level so hot paths never scan the level map to find themselves.
|
||||||
|
boundLevel = level;
|
||||||
Engine cached = engine;
|
Engine cached = engine;
|
||||||
requireCompletedShutdown(cached);
|
requireCompletedShutdown(cached);
|
||||||
if (cached != null && !cached.isClosed() && cached.getComplex() != null) {
|
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");
|
+ "' created an engine without a ready biome complex");
|
||||||
}
|
}
|
||||||
engine = created;
|
engine = created;
|
||||||
|
boundLevel = level;
|
||||||
|
heightMetadata = engineHeights(created);
|
||||||
configuredStructureBiomeKeys = null;
|
configuredStructureBiomeKeys = null;
|
||||||
structureBiomeSource.clearCaches();
|
structureBiomeSource.clearCaches();
|
||||||
engineBinding.complete(created);
|
engineBinding.complete(created);
|
||||||
@@ -376,9 +424,22 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
|||||||
if (enabled) {
|
if (enabled) {
|
||||||
return;
|
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
|
throw new IllegalStateException("Iris generator '" + dimensionKey
|
||||||
+ "' cannot bind while generate-structures=false; set generate-structures=true, restart the server, "
|
+ "' cannot bind while generate-structures=false; " + remedy);
|
||||||
+ "and deny individual structures through importedStructures.disabled");
|
}
|
||||||
|
|
||||||
|
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() {
|
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() {
|
private ModdedDimensionMetadata.ConfiguredPack configuredPack() {
|
||||||
ModdedDimensionMetadata.ConfiguredPack cached = configuredPack;
|
ModdedDimensionMetadata.ConfiguredPack cached = configuredPack;
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
@@ -510,6 +606,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
|||||||
ModdedDimensionMetadata.ConfiguredPack resolved = new ModdedDimensionMetadata.ConfiguredPack(
|
ModdedDimensionMetadata.ConfiguredPack resolved = new ModdedDimensionMetadata.ConfiguredPack(
|
||||||
data, dimension, ModdedDimensionMetadata.dimensionMetadata(dimension));
|
data, dimension, ModdedDimensionMetadata.dimensionMetadata(dimension));
|
||||||
configuredPack = resolved;
|
configuredPack = resolved;
|
||||||
|
heightMetadata = resolved.metadata();
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,6 +615,18 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
|||||||
return dimensionKey;
|
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() {
|
public Engine engineIfBound() {
|
||||||
Engine current = engine;
|
Engine current = engine;
|
||||||
return unloading || current == null || current.isClosing() || current.isClosed() ? null : current;
|
return unloading || current == null || current.isClosing() || current.isClosed() ? null : current;
|
||||||
@@ -534,6 +643,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
|||||||
public void onHotload() {
|
public void onHotload() {
|
||||||
configuredStructureBiomeKeys = null;
|
configuredStructureBiomeKeys = null;
|
||||||
structureBiomeSource.clearCaches();
|
structureBiomeSource.clearCaches();
|
||||||
|
importedFeatures.invalidate();
|
||||||
nativeStructures.clearWorldCheckStructureShifts();
|
nativeStructures.clearWorldCheckStructureShifts();
|
||||||
spawnTables.resetVanillaSpawnBiomes();
|
spawnTables.resetVanillaSpawnBiomes();
|
||||||
}
|
}
|
||||||
@@ -711,9 +821,15 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
|||||||
@Override
|
@Override
|
||||||
public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, StructureManager structureManager) {
|
public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, StructureManager structureManager) {
|
||||||
Engine current = engine();
|
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");
|
try (GenerationSessionLease lease = requireGenerationLease(current, "modded_biome_decoration");
|
||||||
IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) {
|
IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) {
|
||||||
nativeStructures.placeVanillaStructures(level, chunk, structureManager);
|
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() {
|
public int getGenDepth() {
|
||||||
Engine current = engine;
|
Engine current = engine;
|
||||||
return current == null || current.isClosed()
|
return current == null || current.isClosed()
|
||||||
? configuredPack().metadata().depth()
|
? heightMetadata().depth()
|
||||||
: current.getMaxHeight() - current.getMinHeight();
|
: current.getMaxHeight() - current.getMinHeight();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,7 +895,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
|||||||
public int getSeaLevel() {
|
public int getSeaLevel() {
|
||||||
Engine current = engine;
|
Engine current = engine;
|
||||||
return current == null || current.isClosed()
|
return current == null || current.isClosed()
|
||||||
? configuredPack().metadata().seaLevel()
|
? heightMetadata().seaLevel()
|
||||||
: current.getMinHeight() + current.getDimension().getFluidHeight();
|
: current.getMinHeight() + current.getDimension().getFluidHeight();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,7 +903,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
|||||||
public int getMinY() {
|
public int getMinY() {
|
||||||
Engine current = engine;
|
Engine current = engine;
|
||||||
return current == null || current.isClosed()
|
return current == null || current.isClosed()
|
||||||
? configuredPack().metadata().minY()
|
? heightMetadata().minY()
|
||||||
: current.getMinHeight();
|
: current.getMinHeight();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+156
-15
@@ -25,13 +25,21 @@ import java.io.IOException;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public final class MainWorldService {
|
public final class MainWorldService {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||||
private static final String MARKER_NAME = "mainworld.pending";
|
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 = {
|
private static final String[] VANILLA_DIMENSION_FOLDERS = {
|
||||||
"region",
|
"region",
|
||||||
"entities",
|
"entities",
|
||||||
@@ -62,23 +70,46 @@ public final class MainWorldService {
|
|||||||
if (pack == null || pack.isBlank()) {
|
if (pack == null || pack.isBlank()) {
|
||||||
return;
|
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 target = presetIdFor(pack);
|
||||||
String currentType = readProperty(properties, "level-type");
|
String currentType = readProperty(properties, "level-type");
|
||||||
if (!target.equals(currentType)) {
|
if (!target.equals(currentType)) {
|
||||||
writeLevelProperties(properties, target, config.mainWorldSeed());
|
writeLevelProperties(properties, target, config.mainWorldSeed());
|
||||||
markPending();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!isPending()) {
|
if (!isPending()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String levelName = firstNonBlank(readProperty(properties, "level-name"), "world");
|
Path worldRoot;
|
||||||
Path worldRoot = resolveWorldRoot(levelName);
|
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);
|
Path recovery = quarantineVanillaDimensions(worldRoot);
|
||||||
clearPending();
|
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) {
|
} catch (Throwable e) {
|
||||||
LOGGER.error("Iris main world reconciliation failed", e);
|
LOGGER.error("Iris main world reconciliation failed", e);
|
||||||
throw new IllegalStateException(
|
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");
|
LOGGER.error("Iris main-world replacement is only available on dedicated servers; use the Create World generator selector in singleplayer");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
Path instanceRoot = verifiedInstanceRoot("stage the Iris main world");
|
||||||
|
if (instanceRoot == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
Path properties = instanceRoot().resolve("server.properties");
|
writeLevelProperties(instanceRoot.resolve(PROPERTIES_NAME), presetIdFor(packRef), seed);
|
||||||
writeLevelProperties(properties, presetIdFor(packRef), seed);
|
|
||||||
markPending();
|
markPending();
|
||||||
return true;
|
return true;
|
||||||
} catch (IOException e) {
|
} 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() {
|
private static Path markerFile() {
|
||||||
@@ -146,6 +193,11 @@ public final class MainWorldService {
|
|||||||
return null;
|
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 {
|
private static void writeLevelProperties(Path properties, String target, long seed) throws IOException {
|
||||||
List<String> lines = Files.isRegularFile(properties)
|
List<String> lines = Files.isRegularFile(properties)
|
||||||
? new ArrayList<>(Files.readAllLines(properties, StandardCharsets.UTF_8))
|
? new ArrayList<>(Files.readAllLines(properties, StandardCharsets.UTF_8))
|
||||||
@@ -154,7 +206,14 @@ public final class MainWorldService {
|
|||||||
if (seed != 0L) {
|
if (seed != 0L) {
|
||||||
setProperty(lines, "level-seed", Long.toString(seed));
|
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) {
|
private static void setProperty(List<String> lines, String key, String value) {
|
||||||
@@ -168,15 +227,97 @@ public final class MainWorldService {
|
|||||||
lines.add(prefix + value);
|
lines.add(prefix + value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Path resolveWorldRoot(String levelName) throws IOException {
|
/**
|
||||||
Path root = instanceRoot().toAbsolutePath().normalize();
|
* Mirrors net.minecraft.server.Main world resolution: the universe root is --universe (default the working
|
||||||
Path worldRoot = root.resolve(levelName).toAbsolutePath().normalize();
|
* directory) and the world folder name is --world, falling back to the level-name property.
|
||||||
if (worldRoot.equals(root) || !worldRoot.startsWith(root)) {
|
*/
|
||||||
throw new IOException("Unsafe level-name path outside the server instance: " + levelName);
|
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;
|
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 {
|
private static Path quarantineVanillaDimensions(Path worldRoot) throws IOException {
|
||||||
Path recovery = markerFile().getParent().resolve("mainworld-recovery-" + UUID.randomUUID());
|
Path recovery = markerFile().getParent().resolve("mainworld-recovery-" + UUID.randomUUID());
|
||||||
List<Path> moved = new ArrayList<>();
|
List<Path> moved = new ArrayList<>();
|
||||||
|
|||||||
@@ -34,13 +34,20 @@ import org.slf4j.LoggerFactory;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
public final class ModdedBiomeWriter implements PlatformBiomeWriter {
|
public final class ModdedBiomeWriter implements PlatformBiomeWriter {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||||
private static final String VANILLA_FALLBACK_KEY = "minecraft:plains";
|
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 Supplier<MinecraftServer> server;
|
||||||
|
private final AtomicBoolean serverMissingReported = new AtomicBoolean();
|
||||||
|
private volatile RegistryCache cache;
|
||||||
|
|
||||||
public ModdedBiomeWriter(Supplier<MinecraftServer> server) {
|
public ModdedBiomeWriter(Supplier<MinecraftServer> server) {
|
||||||
this.server = server;
|
this.server = server;
|
||||||
@@ -50,9 +57,33 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter {
|
|||||||
public int biomeIdFor(String key) {
|
public int biomeIdFor(String key) {
|
||||||
Registry<Biome> registry = biomeRegistry();
|
Registry<Biome> registry = biomeRegistry();
|
||||||
if (registry == null) {
|
if (registry == null) {
|
||||||
|
reportMissingServer("resolve the biome id for '" + key + "'", "using biome id 0");
|
||||||
return 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) {
|
if (direct >= 0) {
|
||||||
return direct;
|
return direct;
|
||||||
}
|
}
|
||||||
@@ -79,17 +110,25 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter {
|
|||||||
@Override
|
@Override
|
||||||
public List<PlatformBiome> allBiomes() {
|
public List<PlatformBiome> allBiomes() {
|
||||||
Registry<Biome> registry = biomeRegistry();
|
Registry<Biome> registry = biomeRegistry();
|
||||||
List<PlatformBiome> biomes = new ArrayList<>();
|
|
||||||
if (registry == null) {
|
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);
|
RegistryCache cached = cacheFor(registry);
|
||||||
if (biome != null) {
|
List<PlatformBiome> snapshot = cached.biomes;
|
||||||
biomes.add(ModdedBiome.of(biome, identifier.toString()));
|
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) {
|
private int idForKey(Registry<Biome> registry, String key) {
|
||||||
@@ -151,6 +190,44 @@ public final class ModdedBiomeWriter implements PlatformBiomeWriter {
|
|||||||
if (instance == null) {
|
if (instance == null) {
|
||||||
return 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);
|
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) {
|
if (engineFor(level) == null) {
|
||||||
return;
|
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());
|
BreakKey key = new BreakKey(level, position.asLong());
|
||||||
ModdedTreeFellerService treeFeller = treeFellerService();
|
ModdedTreeFellerService treeFeller = treeFellerService();
|
||||||
ModdedTreeFellerService.PreparedOrigin preparedOrigin = treeFeller == null
|
ModdedTreeFellerService.PreparedOrigin preparedOrigin = treeFeller == null
|
||||||
@@ -75,10 +83,7 @@ public final class ModdedBlockBreakHandler {
|
|||||||
: treeFeller.prepare(level, player, position, brokenState);
|
: treeFeller.prepare(level, player, position, brokenState);
|
||||||
PendingBreak pending = new PendingBreak(brokenState, preparedOrigin);
|
PendingBreak pending = new PendingBreak(brokenState, preparedOrigin);
|
||||||
PENDING.put(key, pending);
|
PENDING.put(key, pending);
|
||||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
scheduler.laterGlobal(() -> finishPending(key, pending, position), 1);
|
||||||
if (scheduler != null) {
|
|
||||||
scheduler.laterGlobal(() -> finishPending(key, pending, position), 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void clear() {
|
public static void clear() {
|
||||||
|
|||||||
+29
-9
@@ -210,12 +210,9 @@ public final class ModdedBlockResolution {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Parsed resolveGet(String bdxf) {
|
static Parsed resolveGet(String bdxf) {
|
||||||
Parsed parsed = resolveOrNull(bdxf, false);
|
// Mirrors the Bukkit path: an unknown key warns (rate limited) and falls back to air, instead of
|
||||||
if (parsed != null) {
|
// resolving to air with no output at all.
|
||||||
return parsed;
|
return resolveNoCompat(bdxf);
|
||||||
}
|
|
||||||
IrisLogging.error("Can't find block data for " + bdxf);
|
|
||||||
return new Parsed(AIR, null, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Parsed resolveNoCompat(String bdxf) {
|
static Parsed resolveNoCompat(String bdxf) {
|
||||||
@@ -226,6 +223,10 @@ public final class ModdedBlockResolution {
|
|||||||
return new Parsed(AIR, null, null);
|
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) {
|
static Parsed resolveOrNull(String bdxf, boolean warn) {
|
||||||
try {
|
try {
|
||||||
String bd = bdxf.trim();
|
String bd = bdxf.trim();
|
||||||
@@ -248,7 +249,7 @@ public final class ModdedBlockResolution {
|
|||||||
if (warn) {
|
if (warn) {
|
||||||
warnUnresolved(bd, "Unknown Block Data '" + bd + "'");
|
warnUnresolved(bd, "Unknown Block Data '" + bd + "'");
|
||||||
}
|
}
|
||||||
return new Parsed(AIR, null, null);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return bdx;
|
return bdx;
|
||||||
@@ -286,16 +287,35 @@ public final class ModdedBlockResolution {
|
|||||||
return parseStrict(s);
|
return parseStrict(s);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
if (s.contains("[")) {
|
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) {
|
if (warn) {
|
||||||
IrisLogging.warn("Can't find block data for " + s);
|
warnUnresolved(s, "Can't find block data for " + s);
|
||||||
}
|
}
|
||||||
return null;
|
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) {
|
private static Parsed materialBlockData(String ix) {
|
||||||
if (ix.contains("[") || ix.contains(":")) {
|
if (ix.contains("[") || ix.contains(":")) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+8
-1
@@ -57,12 +57,15 @@ import java.util.UUID;
|
|||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
public final class ModdedDimensionManager {
|
public final class ModdedDimensionManager {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||||
private static final Object LOCK = new Object();
|
private static final Object LOCK = new Object();
|
||||||
private static final ConcurrentHashMap<String, Handle> HANDLES = new ConcurrentHashMap<>();
|
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 static volatile ModdedServerAccess access;
|
||||||
|
|
||||||
private ModdedDimensionManager() {
|
private ModdedDimensionManager() {
|
||||||
@@ -96,6 +99,8 @@ public final class ModdedDimensionManager {
|
|||||||
return handle.level();
|
return handle.level();
|
||||||
}
|
}
|
||||||
ResourceKey<Level> key = levelKey(dimensionId);
|
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()) {
|
for (ServerLevel level : server.getAllLevels()) {
|
||||||
if (level.dimension().equals(key)) {
|
if (level.dimension().equals(key)) {
|
||||||
return level;
|
return level;
|
||||||
@@ -270,6 +275,8 @@ public final class ModdedDimensionManager {
|
|||||||
CompletableFuture
|
CompletableFuture
|
||||||
.supplyAsync(() -> level.getChunkSource().addTicketAndLoadWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1), server)
|
.supplyAsync(() -> level.getChunkSource().addTicketAndLoadWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1), server)
|
||||||
.thenCompose((CompletableFuture<?> inner) -> inner)
|
.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(() -> {
|
.whenComplete((Object result, Throwable error) -> server.execute(() -> {
|
||||||
level.getChunkSource().removeTicketWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1);
|
level.getChunkSource().removeTicketWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1);
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
|
|||||||
+56
@@ -37,10 +37,13 @@ import java.util.ArrayList;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public final class ModdedDimensionRegistryStore {
|
public final class ModdedDimensionRegistryStore {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||||
private static final String FILE_NAME = "iris-dimensions.json";
|
private static final String FILE_NAME = "iris-dimensions.json";
|
||||||
|
private static final Pattern ID_FIELD = Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||||
|
|
||||||
private ModdedDimensionRegistryStore() {
|
private ModdedDimensionRegistryStore() {
|
||||||
}
|
}
|
||||||
@@ -53,6 +56,59 @@ public final class ModdedDimensionRegistryStore {
|
|||||||
return contents(file).dimensions();
|
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) {
|
private static Contents contents(Path file) {
|
||||||
if (!Files.isRegularFile(file)) {
|
if (!Files.isRegularFile(file)) {
|
||||||
return new Contents(new ArrayList<>(), new ArrayList<>());
|
return new Contents(new ArrayList<>(), new ArrayList<>());
|
||||||
|
|||||||
+9
-11
@@ -125,6 +125,9 @@ public final class ModdedEngineBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void serverStarted(MinecraftServer server) {
|
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);
|
bindWorldGenerators(server);
|
||||||
ModdedStartup.runOnce(server);
|
ModdedStartup.runOnce(server);
|
||||||
reconcileSpawn(server);
|
reconcileSpawn(server);
|
||||||
@@ -174,6 +177,10 @@ public final class ModdedEngineBootstrap {
|
|||||||
ModdedScheduler scheduler = schedulerOrNull();
|
ModdedScheduler scheduler = schedulerOrNull();
|
||||||
if (scheduler != null) {
|
if (scheduler != null) {
|
||||||
failure = runStopStage(failure, "scheduler", scheduler::shutdown);
|
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, "generation pool", IrisModdedChunkGenerator::shutdownGenPool);
|
||||||
failure = runStopStage(failure, "sentry", ModdedSentry::flush);
|
failure = runStopStage(failure, "sentry", ModdedSentry::flush);
|
||||||
@@ -184,8 +191,9 @@ public final class ModdedEngineBootstrap {
|
|||||||
initialSpawnWasDefault = false;
|
initialSpawnWasDefault = false;
|
||||||
});
|
});
|
||||||
if (failure != null) {
|
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);
|
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) {
|
private static void captureInitialSpawn(MinecraftServer server) {
|
||||||
if (spawnCaptureServer == server) {
|
if (spawnCaptureServer == server) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+255
-20
@@ -44,12 +44,18 @@ import org.slf4j.LoggerFactory;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.FileVisitResult;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.SimpleFileVisitor;
|
||||||
import java.nio.file.StandardCopyOption;
|
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.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.HexFormat;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -63,20 +69,79 @@ public final class ModdedForcedDatapack {
|
|||||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||||
private static final String PACK_ID = "iris_worldgen";
|
private static final String PACK_ID = "iris_worldgen";
|
||||||
private static final String PACK_FOLDER = "iris";
|
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 Object LOCK = new Object();
|
||||||
private static final AtomicBoolean LOADED = new AtomicBoolean(false);
|
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() {
|
private ModdedForcedDatapack() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static RepositorySource repositorySource() {
|
public static RepositorySource repositorySource() {
|
||||||
return (Consumer<Pack> consumer) -> {
|
return (Consumer<Pack> consumer) -> {
|
||||||
Pack pack = buildPack();
|
Pack pack = servePack();
|
||||||
consumer.accept(pack);
|
consumer.accept(pack);
|
||||||
LOADED.set(true);
|
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() {
|
public static void verifyInjected() {
|
||||||
if (LOADED.get()) {
|
if (LOADED.get()) {
|
||||||
return;
|
return;
|
||||||
@@ -105,11 +170,11 @@ public final class ModdedForcedDatapack {
|
|||||||
try {
|
try {
|
||||||
return requireReadablePack(regenerate());
|
return requireReadablePack(regenerate());
|
||||||
} catch (RuntimeException | Error generationFailure) {
|
} catch (RuntimeException | Error generationFailure) {
|
||||||
Path published = packDirectory();
|
Path lastKnownGood = packDirectory();
|
||||||
if (Files.isRegularFile(published.resolve("pack.mcmeta"))) {
|
if (Files.isRegularFile(lastKnownGood.resolve("pack.mcmeta"))) {
|
||||||
LOGGER.error("Iris kept the last known-good generated datapack after regeneration failed",
|
LOGGER.error("Iris kept the last known-good generated datapack after regeneration failed",
|
||||||
generationFailure);
|
generationFailure);
|
||||||
return requireReadablePack(published);
|
return requireReadablePack(lastKnownGood);
|
||||||
}
|
}
|
||||||
throw generationFailure;
|
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 {
|
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();
|
Path datapackRoot = datapackRoot();
|
||||||
Files.createDirectories(datapackRoot);
|
Files.createDirectories(datapackRoot);
|
||||||
Path stagingDirectory = Files.createTempDirectory(datapackRoot, PACK_FOLDER + ".staging-");
|
Path stagingDirectory = Files.createTempDirectory(datapackRoot, PACK_FOLDER + ".staging-");
|
||||||
@@ -157,6 +264,12 @@ public final class ModdedForcedDatapack {
|
|||||||
writeStagedPack(stagingDirectory);
|
writeStagedPack(stagingDirectory);
|
||||||
requireReadablePack(stagingDirectory);
|
requireReadablePack(stagingDirectory);
|
||||||
publishDirectory(stagingDirectory, packDirectory());
|
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();
|
return packDirectory();
|
||||||
} catch (IOException | RuntimeException | Error failure) {
|
} catch (IOException | RuntimeException | Error failure) {
|
||||||
try {
|
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 {
|
private static void writeStagedPack(Path stagingDirectory) throws IOException {
|
||||||
Map<String, KSet<String>> seenBiomes = new LinkedHashMap<>();
|
Map<String, KSet<String>> seenBiomes = new LinkedHashMap<>();
|
||||||
IDataFixer fixer = DataVersion.getLatest().get();
|
IDataFixer fixer = DataVersion.getLatest().get();
|
||||||
@@ -189,6 +411,9 @@ public final class ModdedForcedDatapack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
writePackMeta(stagingDirectory);
|
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())) {
|
if ("forge".equalsIgnoreCase(ModdedEngineBootstrap.loader().platformName())) {
|
||||||
writeForgeBlockLootModifier(stagingDirectory);
|
writeForgeBlockLootModifier(stagingDirectory);
|
||||||
}
|
}
|
||||||
@@ -211,9 +436,7 @@ public final class ModdedForcedDatapack {
|
|||||||
} catch (Throwable validationFailure) {
|
} catch (Throwable validationFailure) {
|
||||||
LOGGER.error("Iris excluded pack '{}' from Create World because validation failed",
|
LOGGER.error("Iris excluded pack '{}' from Create World because validation failed",
|
||||||
sourcePack.getName(), validationFailure);
|
sourcePack.getName(), validationFailure);
|
||||||
if (validationFailure instanceof Error fatalError) {
|
rethrowIfUnrecoverable(validationFailure);
|
||||||
throw fatalError;
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!validation.isLoadable()) {
|
if (!validation.isLoadable()) {
|
||||||
@@ -235,9 +458,7 @@ public final class ModdedForcedDatapack {
|
|||||||
} catch (Throwable installationFailure) {
|
} catch (Throwable installationFailure) {
|
||||||
LOGGER.error("Iris excluded pack '{}' from Create World because datapack serialization failed",
|
LOGGER.error("Iris excluded pack '{}' from Create World because datapack serialization failed",
|
||||||
sourcePack.getName(), installationFailure);
|
sourcePack.getName(), installationFailure);
|
||||||
if (installationFailure instanceof Error fatalError) {
|
rethrowIfUnrecoverable(installationFailure);
|
||||||
throw fatalError;
|
|
||||||
}
|
|
||||||
installed = false;
|
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 {
|
private static void mergeDirectory(Path sourceDirectory, Path destinationDirectory) throws IOException {
|
||||||
List<Path> entries = new ArrayList<>();
|
List<Path> entries = new ArrayList<>();
|
||||||
try (Stream<Path> walk = Files.walk(sourceDirectory)) {
|
try (Stream<Path> walk = Files.walk(sourceDirectory)) {
|
||||||
@@ -345,15 +580,6 @@ public final class ModdedForcedDatapack {
|
|||||||
.resolve("worldgen").resolve("world_preset").resolve(presetPath + ".json");
|
.resolve("worldgen").resolve("world_preset").resolve(presetPath + ".json");
|
||||||
Files.createDirectories(output.getParent());
|
Files.createDirectories(output.getParent());
|
||||||
Files.writeString(output, json, StandardCharsets.UTF_8);
|
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");
|
.resolve("dimension_type").resolve(typePath + ".json");
|
||||||
Files.createDirectories(output.getParent());
|
Files.createDirectories(output.getParent());
|
||||||
Files.writeString(output, json, StandardCharsets.UTF_8);
|
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")
|
Path legacyOutput = datapackRoot.toPath().resolve("data").resolve("irisworldgen")
|
||||||
.resolve("dimension_type").resolve(dimension.getDimensionTypeKey() + ".json");
|
.resolve("dimension_type").resolve(dimension.getDimensionTypeKey() + ".json");
|
||||||
if (!Files.exists(legacyOutput)) {
|
if (!Files.exists(legacyOutput)) {
|
||||||
@@ -517,4 +746,10 @@ public final class ModdedForcedDatapack {
|
|||||||
private static Path packsRoot() {
|
private static Path packsRoot() {
|
||||||
return ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs");
|
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;
|
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.ExecutorService;
|
||||||
import java.util.concurrent.LinkedBlockingQueue;
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
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 AtomicInteger GEN_THREAD_SEQ = new AtomicInteger();
|
||||||
private static final boolean PARALLEL_CHUNK_SYSTEM = detectParallelChunkSystem();
|
private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem();
|
||||||
private static volatile ExecutorService genPool = createGenPool();
|
private static final AtomicReference<ExecutorService> GEN_POOL = new AtomicReference<>(createGenPool());
|
||||||
|
|
||||||
private ModdedGenPool() {
|
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() {
|
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() {
|
static void start() {
|
||||||
ExecutorService pool = genPool;
|
while (true) {
|
||||||
if (pool == null || pool.isShutdown()) {
|
ExecutorService current = GEN_POOL.get();
|
||||||
genPool = createGenPool();
|
if (current != null && !current.isShutdown()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ExecutorService created = createGenPool();
|
||||||
|
if (GEN_POOL.compareAndSet(current, created)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
created.shutdownNow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void shutdown() {
|
static void shutdown() {
|
||||||
ExecutorService pool = genPool;
|
ExecutorService pool = GEN_POOL.getAndSet(null);
|
||||||
if (pool != null) {
|
if (pool == null) {
|
||||||
pool.shutdownNow();
|
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() {
|
private static Method declaredMethodOrNull(Class<?> type, String name) {
|
||||||
String[] markers = {
|
try {
|
||||||
"com.ishland.c2me.base.ModProperties",
|
Method method = type.getDeclaredMethod(name);
|
||||||
"com.ishland.c2me.base.common.config.C2MEConfig",
|
method.setAccessible(true);
|
||||||
"com.ishland.c2me.opts.chunkio.ModProperties",
|
return method;
|
||||||
"ca.spottedleaf.moonrise.common.util.MoonriseCommon"
|
} 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) {
|
for (String marker : markers) {
|
||||||
try {
|
if (loadOrNull(marker) != null) {
|
||||||
Class.forName(marker, false, IrisModdedChunkGenerator.class.getClassLoader());
|
|
||||||
return true;
|
return true;
|
||||||
} catch (Throwable ignored) {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
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() {
|
private static ExecutorService createGenPool() {
|
||||||
int threads = Math.max(2, Runtime.getRuntime().availableProcessors());
|
int threads = Math.max(2, Runtime.getRuntime().availableProcessors());
|
||||||
ThreadPoolExecutor pool = new ThreadPoolExecutor(
|
ThreadPoolExecutor pool = new ThreadPoolExecutor(
|
||||||
@@ -84,4 +354,7 @@ final class ModdedGenPool {
|
|||||||
pool.allowCoreThreadTimeOut(true);
|
pool.allowCoreThreadTimeOut(true);
|
||||||
return pool;
|
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.Enchantment;
|
||||||
import net.minecraft.world.item.enchantment.ItemEnchantments;
|
import net.minecraft.world.item.enchantment.ItemEnchantments;
|
||||||
|
|
||||||
import java.lang.reflect.Field;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -69,8 +68,6 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
|
|
||||||
public final class ModdedItemTranslator {
|
public final class ModdedItemTranslator {
|
||||||
private static final Set<String> WARNED = ConcurrentHashMap.newKeySet();
|
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 static final String COLOR_CODES = "0123456789AaBbCcDdEeFfKkLlMmNnOoRrXx";
|
||||||
|
|
||||||
private ModdedItemTranslator() {
|
private ModdedItemTranslator() {
|
||||||
@@ -140,7 +137,7 @@ public final class ModdedItemTranslator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ItemStack baseStack(IrisLoot loot, RNG rng) {
|
private static ItemStack baseStack(IrisLoot loot, RNG rng) {
|
||||||
String raw = readString(TYPE_FIELD, loot);
|
String raw = loot.getTypeKey();
|
||||||
if (raw == null || raw.isBlank()) {
|
if (raw == null || raw.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -187,7 +184,7 @@ public final class ModdedItemTranslator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String dye = readString(DYE_COLOR_FIELD, loot);
|
String dye = loot.getDyeColorKey();
|
||||||
if (dye != null) {
|
if (dye != null) {
|
||||||
applyDyeColor(stack, dye);
|
applyDyeColor(stack, dye);
|
||||||
}
|
}
|
||||||
@@ -214,7 +211,9 @@ public final class ModdedItemTranslator {
|
|||||||
if (name == null || name.isBlank()) {
|
if (name == null || name.isBlank()) {
|
||||||
continue;
|
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);
|
Identifier id = Identifier.tryParse(key.contains(":") ? key : "minecraft:" + key);
|
||||||
Optional<Holder.Reference<Enchantment>> holder = id == null ? Optional.empty() : registry.get(id);
|
Optional<Holder.Reference<Enchantment>> holder = id == null ? Optional.empty() : registry.get(id);
|
||||||
if (holder.isEmpty()) {
|
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();
|
ChunkPos disabledChunk = chunk.getPos();
|
||||||
throw new IllegalStateException("Iris cannot generate native structures in chunk "
|
throw new IllegalStateException("Iris cannot generate native structures in chunk "
|
||||||
+ disabledChunk.x() + "," + disabledChunk.z()
|
+ disabledChunk.x() + "," + disabledChunk.z()
|
||||||
+ " because generate-structures=false disables them outside the pack; set generate-structures=true, "
|
+ " because generate-structures=false disables them outside the pack. That flag is fixed when "
|
||||||
+ "restart the server, and deny individual structures through importedStructures.disabled");
|
+ "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();
|
ChunkPos chunkPos = chunk.getPos();
|
||||||
SectionPos sectionPos = SectionPos.of(chunkPos, world.getMinSectionY());
|
SectionPos sectionPos = SectionPos.of(chunkPos, world.getMinSectionY());
|
||||||
|
|||||||
+19
-11
@@ -56,18 +56,26 @@ public final class ModdedPackInstaller {
|
|||||||
synchronized (installLock) {
|
synchronized (installLock) {
|
||||||
File packs = configDir.resolve("irisworldgen").resolve("packs").toFile();
|
File packs = configDir.resolve("irisworldgen").resolve("packs").toFile();
|
||||||
try {
|
try {
|
||||||
if (PackDownloader.isDefaultOverworld(pack)) {
|
boolean installed = PackDownloader.isDefaultOverworld(pack)
|
||||||
return PackDownloader.downloadDefaultOverworld(
|
? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback) != null
|
||||||
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(
|
return installed;
|
||||||
packs,
|
|
||||||
"IrisDimensions/" + pack,
|
|
||||||
branch,
|
|
||||||
forceOverwrite,
|
|
||||||
false,
|
|
||||||
feedback
|
|
||||||
) != null;
|
|
||||||
} catch (IOException error) {
|
} catch (IOException error) {
|
||||||
LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error);
|
LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error);
|
||||||
feedback.accept(IrisLanguage.plain(
|
feedback.accept(IrisLanguage.plain(
|
||||||
|
|||||||
@@ -35,9 +35,17 @@ import net.minecraft.world.entity.EntitySpawnReason;
|
|||||||
import net.minecraft.world.entity.EntityType;
|
import net.minecraft.world.entity.EntityType;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public final class ModdedPlatform implements IrisPlatform {
|
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> ERROR_SINK = null;
|
||||||
private static volatile Consumer<Throwable> CAPTURE_SINK = null;
|
private static volatile Consumer<Throwable> CAPTURE_SINK = null;
|
||||||
|
|
||||||
@@ -166,11 +174,19 @@ public final class ModdedPlatform implements IrisPlatform {
|
|||||||
ModdedIrisLog.info(message);
|
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
|
@Override
|
||||||
public void reportError(Throwable error) {
|
public void reportError(Throwable error) {
|
||||||
if (error == null) {
|
if (error == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!allowErrorReport(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Consumer<Throwable> sink = ERROR_SINK;
|
Consumer<Throwable> sink = ERROR_SINK;
|
||||||
if (sink != null) {
|
if (sink != null) {
|
||||||
sink.accept(error);
|
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) {
|
private static int parseVersion(String raw) {
|
||||||
if (raw == null || raw.isBlank()) {
|
if (raw == null || raw.isBlank()) {
|
||||||
return -1;
|
return -1;
|
||||||
|
|||||||
+10
@@ -44,6 +44,16 @@ public final class ModdedPrimaryWorldRouter {
|
|||||||
routed.clear();
|
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) {
|
public static void tick(MinecraftServer server) {
|
||||||
if (server == null) {
|
if (server == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+52
-13
@@ -34,6 +34,7 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
public final class ModdedProtocolHandler {
|
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, Engine> SESSION_ENGINES = new ConcurrentHashMap<>();
|
||||||
private static final ConcurrentHashMap<String, String> SESSION_LEVELS = 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 ModdedProtocolChannel channel;
|
||||||
private static volatile IrisSessionRegistry registry;
|
private static volatile IrisSessionRegistry registry;
|
||||||
private static volatile IrisProtocolServer protocolServer;
|
private static volatile IrisProtocolServer protocolServer;
|
||||||
private static volatile ModdedProtocolTransport transport;
|
private static volatile ModdedProtocolTransport transport;
|
||||||
|
private static volatile IrisVisionRequestService visionRequests;
|
||||||
private static int dimensionSyncTicks;
|
private static int dimensionSyncTicks;
|
||||||
|
|
||||||
private ModdedProtocolHandler() {
|
private ModdedProtocolHandler() {
|
||||||
@@ -64,6 +67,7 @@ public final class ModdedProtocolHandler {
|
|||||||
}
|
}
|
||||||
SESSION_ENGINES.clear();
|
SESSION_ENGINES.clear();
|
||||||
SESSION_LEVELS.clear();
|
SESSION_LEVELS.clear();
|
||||||
|
SESSION_IDS.clear();
|
||||||
dimensionSyncTicks = 0;
|
dimensionSyncTicks = 0;
|
||||||
IrisSessionRegistry sessionRegistry = new IrisSessionRegistry();
|
IrisSessionRegistry sessionRegistry = new IrisSessionRegistry();
|
||||||
ModdedProtocolTransport serverTransport = new ModdedProtocolTransport(server, boundChannel);
|
ModdedProtocolTransport serverTransport = new ModdedProtocolTransport(server, boundChannel);
|
||||||
@@ -73,53 +77,85 @@ public final class ModdedProtocolHandler {
|
|||||||
return engine == null || engine.isClosed() ? null : engine;
|
return engine == null || engine.isClosed() ? null : engine;
|
||||||
};
|
};
|
||||||
protocol.setEngineResolver(engineResolver);
|
protocol.setEngineResolver(engineResolver);
|
||||||
protocol.setVisionTileHandler(IrisVisionRequestService.create(engineResolver, sessionRegistry));
|
IrisVisionRequestService visionService = IrisVisionRequestService.create(engineResolver, sessionRegistry);
|
||||||
|
protocol.setVisionTileHandler(visionService);
|
||||||
registry = sessionRegistry;
|
registry = sessionRegistry;
|
||||||
transport = serverTransport;
|
transport = serverTransport;
|
||||||
protocolServer = protocol;
|
protocolServer = protocol;
|
||||||
|
visionRequests = visionService;
|
||||||
IrisServices.register(IrisProtocolServer.class, protocol);
|
IrisServices.register(IrisProtocolServer.class, protocol);
|
||||||
if (server.getPlayerList() == null) {
|
if (server.getPlayerList() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
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() {
|
public static void stop() {
|
||||||
IrisServices.remove(IrisProtocolServer.class);
|
IrisServices.remove(IrisProtocolServer.class);
|
||||||
IrisSessionRegistry current = registry;
|
IrisSessionRegistry current = registry;
|
||||||
|
IrisVisionRequestService vision = visionRequests;
|
||||||
if (current != null) {
|
if (current != null) {
|
||||||
for (IrisSession session : current.all()) {
|
for (IrisSession session : current.all()) {
|
||||||
current.unregister(session.id());
|
current.unregister(session.id());
|
||||||
|
if (vision != null) {
|
||||||
|
vision.clearSession(session.id());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SESSION_ENGINES.clear();
|
SESSION_ENGINES.clear();
|
||||||
SESSION_LEVELS.clear();
|
SESSION_LEVELS.clear();
|
||||||
|
SESSION_IDS.clear();
|
||||||
dimensionSyncTicks = 0;
|
dimensionSyncTicks = 0;
|
||||||
registry = null;
|
registry = null;
|
||||||
protocolServer = null;
|
protocolServer = null;
|
||||||
transport = null;
|
transport = null;
|
||||||
|
visionRequests = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void onPlayerJoin(ServerPlayer player) {
|
public static void onPlayerJoin(ServerPlayer player) {
|
||||||
IrisSessionRegistry current = registry;
|
if (player == null) {
|
||||||
ModdedProtocolTransport currentTransport = transport;
|
|
||||||
if (player == null || current == null || currentTransport == null) {
|
|
||||||
return;
|
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) {
|
public static void onPlayerDisconnect(ServerPlayer player) {
|
||||||
IrisSessionRegistry current = registry;
|
if (player == null) {
|
||||||
if (player == null || current == null) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String sessionId = player.getUUID().toString();
|
UUID id = player.getUUID();
|
||||||
current.unregister(sessionId);
|
String sessionId = SESSION_IDS.remove(id);
|
||||||
|
if (sessionId == null) {
|
||||||
|
sessionId = id.toString();
|
||||||
|
}
|
||||||
|
ModdedPrimaryWorldRouter.forget(id);
|
||||||
SESSION_ENGINES.remove(sessionId);
|
SESSION_ENGINES.remove(sessionId);
|
||||||
SESSION_LEVELS.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) {
|
public static void onInbound(ServerPlayer player, byte[] frame) {
|
||||||
@@ -127,7 +163,7 @@ public final class ModdedProtocolHandler {
|
|||||||
if (player == null || frame == null || current == null) {
|
if (player == null || frame == null || current == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String sessionId = player.getUUID().toString();
|
String sessionId = sessionId(player);
|
||||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||||
if (scheduler == null) {
|
if (scheduler == null) {
|
||||||
current.onClientFrame(sessionId, frame);
|
current.onClientFrame(sessionId, frame);
|
||||||
@@ -148,7 +184,7 @@ public final class ModdedProtocolHandler {
|
|||||||
}
|
}
|
||||||
dimensionSyncTicks = 0;
|
dimensionSyncTicks = 0;
|
||||||
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||||
String sessionId = player.getUUID().toString();
|
String sessionId = sessionId(player);
|
||||||
IrisSession session = current.get(sessionId);
|
IrisSession session = current.get(sessionId);
|
||||||
if (session == null || !session.isReady()) {
|
if (session == null || !session.isReady()) {
|
||||||
continue;
|
continue;
|
||||||
@@ -167,6 +203,8 @@ public final class ModdedProtocolHandler {
|
|||||||
|
|
||||||
private static boolean syncDimension(IrisProtocolServer protocol, String sessionId, ServerLevel level, String levelId) {
|
private static boolean syncDimension(IrisProtocolServer protocol, String sessionId, ServerLevel level, String levelId) {
|
||||||
ChunkGenerator generator = level.getChunkSource().getGenerator();
|
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;
|
Engine engine = generator instanceof IrisModdedChunkGenerator irisGenerator ? resolveEngine(level, irisGenerator) : null;
|
||||||
if (generator instanceof IrisModdedChunkGenerator && engine == null) {
|
if (generator instanceof IrisModdedChunkGenerator && engine == null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -185,7 +223,8 @@ public final class ModdedProtocolHandler {
|
|||||||
|
|
||||||
private static Engine resolveEngine(ServerLevel level, IrisModdedChunkGenerator generator) {
|
private static Engine resolveEngine(ServerLevel level, IrisModdedChunkGenerator generator) {
|
||||||
try {
|
try {
|
||||||
return generator.commandEngine();
|
Engine engine = generator.engineIfBound();
|
||||||
|
return engine == null || engine.isClosed() ? null : engine;
|
||||||
} catch (Throwable failure) {
|
} catch (Throwable failure) {
|
||||||
LOGGER.error("Iris dimension status engine lookup failed for {}", level.dimension().identifier(), failure);
|
LOGGER.error("Iris dimension status engine lookup failed for {}", level.dimension().identifier(), failure);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -18,12 +18,16 @@
|
|||||||
|
|
||||||
package art.arcane.iris.modded;
|
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.PlatformBiome;
|
||||||
import art.arcane.iris.spi.PlatformBlockProperty;
|
import art.arcane.iris.spi.PlatformBlockProperty;
|
||||||
import art.arcane.iris.spi.PlatformBlockState;
|
import art.arcane.iris.spi.PlatformBlockState;
|
||||||
import art.arcane.iris.spi.PlatformEntityType;
|
import art.arcane.iris.spi.PlatformEntityType;
|
||||||
import art.arcane.iris.spi.PlatformItem;
|
import art.arcane.iris.spi.PlatformItem;
|
||||||
import art.arcane.iris.spi.PlatformRegistries;
|
import art.arcane.iris.spi.PlatformRegistries;
|
||||||
|
import art.arcane.volmlib.util.data.UnresolvedKeyLog;
|
||||||
import net.minecraft.core.Registry;
|
import net.minecraft.core.Registry;
|
||||||
import net.minecraft.core.registries.BuiltInRegistries;
|
import net.minecraft.core.registries.BuiltInRegistries;
|
||||||
import net.minecraft.core.registries.Registries;
|
import net.minecraft.core.registries.Registries;
|
||||||
@@ -45,6 +49,8 @@ import java.util.Map;
|
|||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
public final class ModdedRegistries implements PlatformRegistries {
|
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;
|
private final Supplier<MinecraftServer> server;
|
||||||
|
|
||||||
public ModdedRegistries(Supplier<MinecraftServer> server) {
|
public ModdedRegistries(Supplier<MinecraftServer> server) {
|
||||||
@@ -126,6 +132,7 @@ public final class ModdedRegistries implements PlatformRegistries {
|
|||||||
List<String> keys = new ArrayList<>();
|
List<String> keys = new ArrayList<>();
|
||||||
Registry<Biome> registry = biomeRegistry();
|
Registry<Biome> registry = biomeRegistry();
|
||||||
if (registry == null) {
|
if (registry == null) {
|
||||||
|
warnNotReady("biome");
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
for (Identifier identifier : registry.keySet()) {
|
for (Identifier identifier : registry.keySet()) {
|
||||||
@@ -139,6 +146,7 @@ public final class ModdedRegistries implements PlatformRegistries {
|
|||||||
List<String> keys = new ArrayList<>();
|
List<String> keys = new ArrayList<>();
|
||||||
MinecraftServer instance = server.get();
|
MinecraftServer instance = server.get();
|
||||||
if (instance == null) {
|
if (instance == null) {
|
||||||
|
warnNotReady("structure");
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
for (Identifier identifier : instance.registryAccess().lookupOrThrow(Registries.STRUCTURE).keySet()) {
|
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()) {
|
for (Identifier identifier : BuiltInRegistries.ITEM.keySet()) {
|
||||||
keys.add(identifier.toString());
|
keys.add(identifier.toString());
|
||||||
}
|
}
|
||||||
|
keys.addAll(ModdedCustomContentRegistry.providerKeys(ModdedDataType.ITEM));
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> specialEntityKeys() {
|
||||||
|
return ModdedCustomContentRegistry.providerKeys(ModdedDataType.ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<String> entityKeys() {
|
public List<String> entityKeys() {
|
||||||
List<String> keys = new ArrayList<>();
|
List<String> keys = new ArrayList<>();
|
||||||
@@ -171,6 +185,7 @@ public final class ModdedRegistries implements PlatformRegistries {
|
|||||||
for (Identifier identifier : BuiltInRegistries.BLOCK.keySet()) {
|
for (Identifier identifier : BuiltInRegistries.BLOCK.keySet()) {
|
||||||
keys.add(identifier.toString());
|
keys.add(identifier.toString());
|
||||||
}
|
}
|
||||||
|
keys.addAll(customBlockKeys());
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +194,7 @@ public final class ModdedRegistries implements PlatformRegistries {
|
|||||||
List<String> keys = new ArrayList<>();
|
List<String> keys = new ArrayList<>();
|
||||||
Registry<Enchantment> registry = enchantmentRegistry();
|
Registry<Enchantment> registry = enchantmentRegistry();
|
||||||
if (registry == null) {
|
if (registry == null) {
|
||||||
|
warnNotReady("enchantment");
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
for (Identifier identifier : registry.keySet()) {
|
for (Identifier identifier : registry.keySet()) {
|
||||||
@@ -199,17 +215,50 @@ public final class ModdedRegistries implements PlatformRegistries {
|
|||||||
@Override
|
@Override
|
||||||
public Map<String, List<PlatformBlockProperty>> blockStateProperties() {
|
public Map<String, List<PlatformBlockProperty>> blockStateProperties() {
|
||||||
Map<String, List<PlatformBlockProperty>> properties = new LinkedHashMap<>();
|
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) {
|
for (Block block : BuiltInRegistries.BLOCK) {
|
||||||
BlockState defaultState = block.defaultBlockState();
|
BlockState defaultState = block.defaultBlockState();
|
||||||
List<PlatformBlockProperty> converted = new ArrayList<>();
|
List<PlatformBlockProperty> converted = new ArrayList<>();
|
||||||
for (Property<?> property : block.getStateDefinition().getProperties()) {
|
for (Property<?> property : block.getStateDefinition().getProperties()) {
|
||||||
converted.add(convertProperty(property, defaultState));
|
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;
|
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() {
|
private Registry<Biome> biomeRegistry() {
|
||||||
MinecraftServer instance = server.get();
|
MinecraftServer instance = server.get();
|
||||||
if (instance == null) {
|
if (instance == null) {
|
||||||
|
|||||||
+22
-2
@@ -47,7 +47,9 @@ public final class ModdedRuntimeRegistry {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("Iris dimension type '" + typeRef
|
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) {
|
static void ensureCustomBiomes(RegistryAccess registryAccess, IrisDimension dimension, String pack) {
|
||||||
@@ -78,8 +80,26 @@ public final class ModdedRuntimeRegistry {
|
|||||||
}
|
}
|
||||||
if (!missing.isEmpty()) {
|
if (!missing.isEmpty()) {
|
||||||
throw new IllegalStateException("Iris pack '" + pack + "' has " + missing.size()
|
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());
|
+ 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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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.BlockingQueue;
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
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.ThreadFactory;
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
public final class ModdedScheduler implements PlatformScheduler {
|
public final class ModdedScheduler implements PlatformScheduler {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
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_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 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 static volatile Thread mainThread;
|
||||||
|
|
||||||
private volatile ThreadPoolExecutor asyncExecutor;
|
private volatile ThreadPoolExecutor asyncExecutor;
|
||||||
private final ConcurrentLinkedQueue<Runnable> mainQueue = new ConcurrentLinkedQueue<>();
|
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() {
|
public ModdedScheduler() {
|
||||||
this.asyncExecutor = createAsyncExecutor();
|
this.asyncExecutor = createAsyncExecutor();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ThreadPoolExecutor 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(
|
ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
||||||
ASYNC_CORE_THREADS,
|
ASYNC_MAX_THREADS,
|
||||||
ASYNC_MAX_THREADS,
|
ASYNC_MAX_THREADS,
|
||||||
ASYNC_KEEP_ALIVE_SECONDS,
|
ASYNC_KEEP_ALIVE_SECONDS,
|
||||||
TimeUnit.SECONDS,
|
TimeUnit.SECONDS,
|
||||||
workQueue,
|
workQueue,
|
||||||
new AsyncThreadFactory(),
|
new AsyncThreadFactory(),
|
||||||
new ThreadPoolExecutor.CallerRunsPolicy());
|
dropRejectedTask());
|
||||||
executor.allowCoreThreadTimeOut(true);
|
executor.allowCoreThreadTimeOut(true);
|
||||||
return executor;
|
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) {
|
public static void tick(MinecraftServer server) {
|
||||||
if (server == null) {
|
if (server == null) {
|
||||||
return;
|
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();
|
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||||
if (scheduler == null) {
|
if (scheduler == null) {
|
||||||
return;
|
return;
|
||||||
@@ -99,7 +125,9 @@ public final class ModdedScheduler implements PlatformScheduler {
|
|||||||
if (task == null) {
|
if (task == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
asyncExecutor.execute(() -> runGuarded(task));
|
ThreadPoolExecutor executor = asyncExecutor;
|
||||||
|
warnOnBacklog(executor);
|
||||||
|
executor.execute(() -> runGuarded(task));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -111,7 +139,7 @@ public final class ModdedScheduler implements PlatformScheduler {
|
|||||||
global(task);
|
global(task);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
delayedQueue.add(new DelayedTask(task, ticks));
|
delayedQueue.add(new DelayedTask(currentTick.get() + ticks, delayedSequence.getAndIncrement(), task));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -127,7 +155,10 @@ public final class ModdedScheduler implements PlatformScheduler {
|
|||||||
}
|
}
|
||||||
mainQueue.clear();
|
mainQueue.clear();
|
||||||
delayedQueue.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() {
|
public void shutdown() {
|
||||||
@@ -135,30 +166,54 @@ public final class ModdedScheduler implements PlatformScheduler {
|
|||||||
mainQueue.clear();
|
mainQueue.clear();
|
||||||
delayedQueue.clear();
|
delayedQueue.clear();
|
||||||
mainThread = null;
|
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() {
|
private void drain() {
|
||||||
promoteDelayed();
|
long tick = currentTick.incrementAndGet();
|
||||||
|
promoteDelayed(tick);
|
||||||
|
long deadline = System.nanoTime() + DRAIN_BUDGET_NANOS;
|
||||||
|
int executed = 0;
|
||||||
Runnable task;
|
Runnable task;
|
||||||
while ((task = mainQueue.poll()) != null) {
|
while ((task = mainQueue.poll()) != null) {
|
||||||
runGuarded(task);
|
runGuarded(task);
|
||||||
|
executed++;
|
||||||
|
if (executed >= DRAIN_TASK_CAP || System.nanoTime() >= deadline) {
|
||||||
|
// Budget spent; the remainder stays queued in order and runs next tick.
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void promoteDelayed() {
|
/**
|
||||||
if (delayedQueue.isEmpty()) {
|
* Due-ordered promotion; only the tick thread polls, so the head is always the earliest due task. A task
|
||||||
return;
|
* 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
|
||||||
List<DelayedTask> retained = new ArrayList<>();
|
* rebuild of the pending set.
|
||||||
DelayedTask delayed;
|
*/
|
||||||
while ((delayed = delayedQueue.poll()) != null) {
|
private void promoteDelayed(long tick) {
|
||||||
if (delayed.tick()) {
|
DelayedTask head;
|
||||||
mainQueue.add(delayed.task());
|
while ((head = delayedQueue.peek()) != null && head.dueTick() <= tick) {
|
||||||
} else {
|
DelayedTask delayed = delayedQueue.poll();
|
||||||
retained.add(delayed);
|
if (delayed == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
mainQueue.add(delayed.task());
|
||||||
}
|
}
|
||||||
delayedQueue.addAll(retained);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean onMainThread() {
|
private boolean onMainThread() {
|
||||||
@@ -174,22 +229,11 @@ public final class ModdedScheduler implements PlatformScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class DelayedTask {
|
private record DelayedTask(long dueTick, long sequence, Runnable task) implements Comparable<DelayedTask> {
|
||||||
private final Runnable task;
|
@Override
|
||||||
private int remaining;
|
public int compareTo(DelayedTask other) {
|
||||||
|
int byTick = Long.compare(dueTick, other.dueTick);
|
||||||
private DelayedTask(Runnable task, int remaining) {
|
return byTick != 0 ? byTick : Long.compare(sequence, other.sequence);
|
||||||
this.task = task;
|
|
||||||
this.remaining = remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean tick() {
|
|
||||||
remaining--;
|
|
||||||
return remaining <= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Runnable task() {
|
|
||||||
return task;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,18 +23,113 @@ import net.minecraft.server.MinecraftServer;
|
|||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
import net.minecraft.world.level.Level;
|
import net.minecraft.world.level.Level;
|
||||||
import net.minecraft.world.level.storage.LevelStorageSource;
|
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.Objects;
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public final class ModdedServerLevels implements ModdedServerAccess {
|
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;
|
private final Consumer<MinecraftServer> levelCacheInvalidator;
|
||||||
|
|
||||||
public ModdedServerLevels(Consumer<MinecraftServer> levelCacheInvalidator) {
|
public ModdedServerLevels(Consumer<MinecraftServer> levelCacheInvalidator) {
|
||||||
this.levelCacheInvalidator = Objects.requireNonNull(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
|
@Override
|
||||||
public Executor levelExecutor(MinecraftServer server) {
|
public Executor levelExecutor(MinecraftServer server) {
|
||||||
return server.executor;
|
return server.executor;
|
||||||
@@ -49,6 +144,7 @@ public final class ModdedServerLevels implements ModdedServerAccess {
|
|||||||
public ServerLevel putLevel(MinecraftServer server, ResourceKey<Level> key, ServerLevel level) {
|
public ServerLevel putLevel(MinecraftServer server, ResourceKey<Level> key, ServerLevel level) {
|
||||||
ServerLevel previous = server.levels.put(key, level);
|
ServerLevel previous = server.levels.put(key, level);
|
||||||
if (previous != level) {
|
if (previous != level) {
|
||||||
|
capture(server);
|
||||||
levelCacheInvalidator.accept(server);
|
levelCacheInvalidator.accept(server);
|
||||||
}
|
}
|
||||||
return previous;
|
return previous;
|
||||||
@@ -58,6 +154,7 @@ public final class ModdedServerLevels implements ModdedServerAccess {
|
|||||||
public ServerLevel putLevelIfAbsent(MinecraftServer server, ResourceKey<Level> key, ServerLevel level) {
|
public ServerLevel putLevelIfAbsent(MinecraftServer server, ResourceKey<Level> key, ServerLevel level) {
|
||||||
ServerLevel previous = server.levels.putIfAbsent(key, level);
|
ServerLevel previous = server.levels.putIfAbsent(key, level);
|
||||||
if (previous == null) {
|
if (previous == null) {
|
||||||
|
capture(server);
|
||||||
levelCacheInvalidator.accept(server);
|
levelCacheInvalidator.accept(server);
|
||||||
}
|
}
|
||||||
return previous;
|
return previous;
|
||||||
@@ -67,6 +164,7 @@ public final class ModdedServerLevels implements ModdedServerAccess {
|
|||||||
public ServerLevel removeLevel(MinecraftServer server, ResourceKey<Level> key) {
|
public ServerLevel removeLevel(MinecraftServer server, ResourceKey<Level> key) {
|
||||||
ServerLevel removed = server.levels.remove(key);
|
ServerLevel removed = server.levels.remove(key);
|
||||||
if (removed != null) {
|
if (removed != null) {
|
||||||
|
capture(server);
|
||||||
levelCacheInvalidator.accept(server);
|
levelCacheInvalidator.accept(server);
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
@@ -76,4 +174,8 @@ public final class ModdedServerLevels implements ModdedServerAccess {
|
|||||||
public boolean hasLevel(MinecraftServer server, ResourceKey<Level> key) {
|
public boolean hasLevel(MinecraftServer server, ResourceKey<Level> key) {
|
||||||
return server.levels.containsKey(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() {
|
public synchronized void disableAll() {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Throwable failure = null;
|
Throwable failure = null;
|
||||||
|
int failed = 0;
|
||||||
ModdedService[] ordered = services.values().toArray(new ModdedService[0]);
|
ModdedService[] ordered = services.values().toArray(new ModdedService[0]);
|
||||||
for (int i = ordered.length - 1; i >= 0; i--) {
|
for (int i = ordered.length - 1; i >= 0; i--) {
|
||||||
ModdedService service = ordered[i];
|
ModdedService service = ordered[i];
|
||||||
try {
|
try {
|
||||||
service.onDisable();
|
service.onDisable();
|
||||||
} catch (Throwable serviceFailure) {
|
} catch (Throwable serviceFailure) {
|
||||||
|
failed++;
|
||||||
LOGGER.error("Iris service onDisable failed for {}", service.getClass().getName(), serviceFailure);
|
LOGGER.error("Iris service onDisable failed for {}", service.getClass().getName(), serviceFailure);
|
||||||
if (failure == null) {
|
if (failure == null) {
|
||||||
failure = serviceFailure;
|
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;
|
enabled = false;
|
||||||
|
if (failure != null) {
|
||||||
|
LOGGER.error("Iris disabled all services with {} failure(s)", failed, failure);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized void rollback(Throwable 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.PackValidationResult;
|
||||||
import art.arcane.iris.core.pack.PackValidator;
|
import art.arcane.iris.core.pack.PackValidator;
|
||||||
import art.arcane.iris.modded.command.ModdedPackCommands;
|
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.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
public final class ModdedStartup {
|
public final class ModdedStartup {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||||
@@ -54,17 +62,26 @@ public final class ModdedStartup {
|
|||||||
validateAllPacks();
|
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() {
|
public static void prefetchDefaultPack() {
|
||||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
Thread thread = new Thread(ModdedStartup::refreshPacksAndDatapack, "iris-modded-pack-prefetch");
|
||||||
if (scheduler != null) {
|
|
||||||
scheduler.async(ModdedStartup::ensureDefaultPack);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Thread thread = new Thread(ModdedStartup::ensureDefaultPack, "iris-modded-pack-prefetch");
|
|
||||||
thread.setDaemon(true);
|
thread.setDaemon(true);
|
||||||
thread.start();
|
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) {
|
public static void runOnce(MinecraftServer server) {
|
||||||
if (server == null || server.getPlayerList() == null) {
|
if (server == null || server.getPlayerList() == null) {
|
||||||
return;
|
return;
|
||||||
@@ -74,14 +91,15 @@ public final class ModdedStartup {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ModdedForcedDatapack.verifyInjected();
|
ModdedForcedDatapack.verifyInjected();
|
||||||
|
ModdedMixinAudit.runOnce();
|
||||||
reinjectPersistentDimensions(server);
|
reinjectPersistentDimensions(server);
|
||||||
|
|
||||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||||
if (scheduler == null) {
|
if (scheduler == null) {
|
||||||
ensureDefaultPack();
|
refreshPacksAndDatapack();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scheduler.async(ModdedStartup::ensureDefaultPack);
|
scheduler.async(ModdedStartup::refreshPacksAndDatapack);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void validateAllPacks() {
|
public static void validateAllPacks() {
|
||||||
@@ -133,6 +151,12 @@ public final class ModdedStartup {
|
|||||||
throw new BrokenPackException(pack, List.of(
|
throw new BrokenPackException(pack, List.of(
|
||||||
"Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + "."));
|
"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 {
|
try {
|
||||||
PackValidationResult result = PackValidator.validate(packDir);
|
PackValidationResult result = PackValidator.validate(packDir);
|
||||||
PackValidationRegistry.publish(result);
|
PackValidationRegistry.publish(result);
|
||||||
@@ -156,23 +180,70 @@ public final class ModdedStartup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void reinjectPersistentDimensions(MinecraftServer server) {
|
private static void reinjectPersistentDimensions(MinecraftServer server) {
|
||||||
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions = ModdedDimensionRegistryStore.load(server);
|
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
|
||||||
|
ModdedDimensionRegistryStore.loadForStartup(server);
|
||||||
if (dimensions.isEmpty()) {
|
if (dimensions.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
int injected = 0;
|
int injected = 0;
|
||||||
|
int index = 0;
|
||||||
|
long startedAt = System.currentTimeMillis();
|
||||||
for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) {
|
for (ModdedDimensionRegistryStore.PersistentDimension dimension : dimensions) {
|
||||||
|
index++;
|
||||||
|
long dimensionStartedAt = System.currentTimeMillis();
|
||||||
try {
|
try {
|
||||||
ModdedDimensionManager.create(server, dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed());
|
ModdedDimensionManager.create(server, dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed());
|
||||||
injected++;
|
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) {
|
} catch (Throwable e) {
|
||||||
LOGGER.error("Iris failed to re-inject persistent dimension '{}' (pack={} dim={} seed={})", dimension.id(), dimension.pack(), dimension.dimension(), dimension.seed(), 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) {
|
if (e instanceof OutOfMemoryError outOfMemory) {
|
||||||
throw fatalError;
|
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() {
|
public static void ensureDefaultPack() {
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ import java.util.Set;
|
|||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
public final class ModdedStructureHooks implements PlatformStructureHooks {
|
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;
|
private final Supplier<MinecraftServer> server;
|
||||||
|
|
||||||
public ModdedStructureHooks(Supplier<MinecraftServer> server) {
|
public ModdedStructureHooks(Supplier<MinecraftServer> server) {
|
||||||
@@ -182,6 +189,13 @@ public final class ModdedStructureHooks implements PlatformStructureHooks {
|
|||||||
return keys;
|
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
|
@Override
|
||||||
public boolean placeFeature(PlatformWorld world, int x, int y, int z, String featureKey, long seed) {
|
public boolean placeFeature(PlatformWorld world, int x, int y, int z, String featureKey, long seed) {
|
||||||
try {
|
try {
|
||||||
@@ -212,6 +226,10 @@ public final class ModdedStructureHooks implements PlatformStructureHooks {
|
|||||||
if (level == null || identifier == null) {
|
if (level == null || identifier == null) {
|
||||||
return 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();
|
ChunkGenerator generator = level.getChunkSource().getGenerator();
|
||||||
Registry<Structure> registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
|
Registry<Structure> registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
|
||||||
Structure structure = registry.getValue(identifier);
|
Structure structure = registry.getValue(identifier);
|
||||||
@@ -244,6 +262,12 @@ public final class ModdedStructureHooks implements PlatformStructureHooks {
|
|||||||
if (!isWithinSpan(box, maxSpan)) {
|
if (!isWithinSpan(box, maxSpan)) {
|
||||||
return null;
|
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);
|
placeChunks(level, structureManager, generator, start, box, seed);
|
||||||
return bounds(box);
|
return bounds(box);
|
||||||
} catch (RuntimeException error) {
|
} catch (RuntimeException error) {
|
||||||
@@ -275,6 +299,20 @@ public final class ModdedStructureHooks implements PlatformStructureHooks {
|
|||||||
|| box.getXSpan() <= maxSpan && box.getYSpan() <= maxSpan && box.getZSpan() <= maxSpan;
|
|| 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) {
|
static int[] bounds(BoundingBox box) {
|
||||||
return new int[]{box.minX(), box.minY(), box.minZ(), box.maxX(), box.maxY(), box.maxZ()};
|
return new int[]{box.minX(), box.minY(), box.minZ(), box.maxX(), box.maxY(), box.maxZ()};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,15 @@
|
|||||||
package art.arcane.iris.modded;
|
package art.arcane.iris.modded;
|
||||||
|
|
||||||
import art.arcane.iris.engine.object.TileData;
|
import art.arcane.iris.engine.object.TileData;
|
||||||
|
import art.arcane.iris.spi.IrisLogging;
|
||||||
import art.arcane.iris.spi.PlatformBlockState;
|
import art.arcane.iris.spi.PlatformBlockState;
|
||||||
import art.arcane.volmlib.util.collection.KMap;
|
import art.arcane.volmlib.util.collection.KMap;
|
||||||
|
import art.arcane.volmlib.util.data.UnresolvedKeyLog;
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
import com.google.gson.Strictness;
|
import com.google.gson.Strictness;
|
||||||
import net.minecraft.nbt.ByteTag;
|
import net.minecraft.nbt.ByteTag;
|
||||||
|
import net.minecraft.nbt.CollectionTag;
|
||||||
import net.minecraft.nbt.CompoundTag;
|
import net.minecraft.nbt.CompoundTag;
|
||||||
import net.minecraft.nbt.DoubleTag;
|
import net.minecraft.nbt.DoubleTag;
|
||||||
import net.minecraft.nbt.FloatTag;
|
import net.minecraft.nbt.FloatTag;
|
||||||
@@ -32,6 +35,7 @@ import net.minecraft.nbt.IntTag;
|
|||||||
import net.minecraft.nbt.ListTag;
|
import net.minecraft.nbt.ListTag;
|
||||||
import net.minecraft.nbt.LongTag;
|
import net.minecraft.nbt.LongTag;
|
||||||
import net.minecraft.nbt.NbtUtils;
|
import net.minecraft.nbt.NbtUtils;
|
||||||
|
import net.minecraft.nbt.NumericTag;
|
||||||
import net.minecraft.nbt.ShortTag;
|
import net.minecraft.nbt.ShortTag;
|
||||||
import net.minecraft.nbt.StringTag;
|
import net.minecraft.nbt.StringTag;
|
||||||
import net.minecraft.nbt.Tag;
|
import net.minecraft.nbt.Tag;
|
||||||
@@ -52,18 +56,24 @@ import net.minecraft.world.level.storage.TagValueInput;
|
|||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.DataOutputStream;
|
import java.io.DataOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
public final class ModdedTileData extends TileData {
|
public final class ModdedTileData extends TileData {
|
||||||
public static final String NBT_PROPERTY = "nbt";
|
public static final String NBT_PROPERTY = "nbt";
|
||||||
static final String LEGACY_BANNER_COLOR_PROPERTY = "iris:legacy_banner_color";
|
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 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 byte[] raw;
|
||||||
private final KMap<String, Object> tileProperties;
|
private final KMap<String, Object> tileProperties;
|
||||||
private final String expectedBlockKey;
|
private final String expectedBlockKey;
|
||||||
private final int legacyType;
|
private final int legacyType;
|
||||||
|
private int hash;
|
||||||
|
|
||||||
ModdedTileData(byte[] raw, KMap<String, Object> tileProperties, String expectedBlockKey, int legacyType) {
|
ModdedTileData(byte[] raw, KMap<String, Object> tileProperties, String expectedBlockKey, int legacyType) {
|
||||||
super();
|
super();
|
||||||
@@ -74,8 +84,7 @@ public final class ModdedTileData extends TileData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static ModdedTileData capture(String blockKey, String snbt) throws IOException {
|
public static ModdedTileData capture(String blockKey, String snbt) throws IOException {
|
||||||
KMap<String, Object> properties = new KMap<>();
|
KMap<String, Object> properties = captureProperties(blockKey, snbt);
|
||||||
properties.put(NBT_PROPERTY, snbt);
|
|
||||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||||
try (DataOutputStream out = new DataOutputStream(bytes)) {
|
try (DataOutputStream out = new DataOutputStream(bytes)) {
|
||||||
out.writeUTF(blockKey);
|
out.writeUTF(blockKey);
|
||||||
@@ -84,6 +93,44 @@ public final class ModdedTileData extends TileData {
|
|||||||
return new ModdedTileData(bytes.toByteArray(), properties, normalizeBlockKey(blockKey), -1);
|
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) {
|
public static ModdedTileData fromProperties(PlatformBlockState state, KMap<String, Object> properties) {
|
||||||
String blockKey = state.placementBaseState().key();
|
String blockKey = state.placementBaseState().key();
|
||||||
int bracket = blockKey.indexOf('[');
|
int bracket = blockKey.indexOf('[');
|
||||||
@@ -121,6 +168,17 @@ public final class ModdedTileData extends TileData {
|
|||||||
return true;
|
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) {
|
public boolean isApplicable(BlockState state, BlockEntity blockEntity) {
|
||||||
if (expectedBlockKey != null) {
|
if (expectedBlockKey != null) {
|
||||||
return expectedBlockKey.equals(normalizeBlockKey(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString()));
|
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));
|
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) {
|
private static <T extends Comparable<T>> BlockState copyProperty(BlockState target, BlockState source, Property<T> property) {
|
||||||
return target.setValue(property, source.getValue(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) {
|
static String normalizeBlockKey(String blockKey) {
|
||||||
if (blockKey == null || blockKey.isBlank()) {
|
if (blockKey == null || blockKey.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
@@ -248,8 +366,53 @@ public final class ModdedTileData extends TileData {
|
|||||||
out.write(raw);
|
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
|
@Override
|
||||||
public TileData clone() {
|
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 art.arcane.iris.modded.WorldCheckStructureAudit.PoiAudit;
|
||||||
import net.minecraft.core.BlockPos;
|
import net.minecraft.core.BlockPos;
|
||||||
import net.minecraft.core.registries.BuiltInRegistries;
|
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.MinecraftServer;
|
||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
import net.minecraft.world.level.block.state.BlockState;
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
@@ -39,6 +42,8 @@ import java.util.HexFormat;
|
|||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.TimeoutException;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.function.BooleanSupplier;
|
import java.util.function.BooleanSupplier;
|
||||||
|
|
||||||
@@ -47,8 +52,11 @@ public final class ModdedWorldCheck {
|
|||||||
private static final int EXIT_FAILURE = 1;
|
private static final int EXIT_FAILURE = 1;
|
||||||
private static final long SERVER_WAIT_TIMEOUT_MILLIS = 600000L;
|
private static final long SERVER_WAIT_TIMEOUT_MILLIS = 600000L;
|
||||||
private static final long SERVER_WAIT_INTERVAL_MILLIS = 250L;
|
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 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 static volatile MinecraftServer startedServer;
|
||||||
|
|
||||||
private ModdedWorldCheck() {
|
private ModdedWorldCheck() {
|
||||||
@@ -70,7 +78,9 @@ public final class ModdedWorldCheck {
|
|||||||
|
|
||||||
static Thread coordinatorThread(Runnable coordinator) {
|
static Thread coordinatorThread(Runnable coordinator) {
|
||||||
Thread thread = new Thread(coordinator, "Iris World Check");
|
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;
|
return thread;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,17 +105,20 @@ public final class ModdedWorldCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MinecraftServer serverRef = server;
|
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(
|
exitCode = serverRef.submit(() -> runAndRequestStop(
|
||||||
() -> completeWorldCheck(preparation),
|
() -> completeWorldCheck(preparation),
|
||||||
() -> {
|
() -> {
|
||||||
stopRequested.set(true);
|
stopRequested.set(true);
|
||||||
serverRef.halt(false);
|
serverRef.halt(false);
|
||||||
}
|
}
|
||||||
)).join();
|
)).get(SERVER_TASK_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
LOGGER.error("[worldcheck] coordinator interrupted", e);
|
LOGGER.error("[worldcheck] coordinator interrupted", e);
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
|
} catch (TimeoutException e) {
|
||||||
|
LOGGER.error("[worldcheck] server task did not finish within {}ms", SERVER_TASK_TIMEOUT_MILLIS);
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
LOGGER.error("[worldcheck] check failed", e);
|
LOGGER.error("[worldcheck] check failed", e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -275,16 +288,18 @@ public final class ModdedWorldCheck {
|
|||||||
private static ServerLevel targetLevel(MinecraftServer server) {
|
private static ServerLevel targetLevel(MinecraftServer server) {
|
||||||
String target = System.getProperty("iris.worldcheck.dimension");
|
String target = System.getProperty("iris.worldcheck.dimension");
|
||||||
if (target != null && !target.isBlank()) {
|
if (target != null && !target.isBlank()) {
|
||||||
for (ServerLevel level : server.getAllLevels()) {
|
Identifier identifier = Identifier.tryParse(target.trim());
|
||||||
if (level.dimension().identifier().toString().equals(target.trim())) {
|
ServerLevel requested = identifier == null
|
||||||
return level;
|
? null
|
||||||
}
|
: ModdedServerLevels.level(server, ResourceKey.create(Registries.DIMENSION, identifier));
|
||||||
|
if (requested != null) {
|
||||||
|
return requested;
|
||||||
}
|
}
|
||||||
LOGGER.error("[worldcheck] requested dimension '{}' is not loaded", target);
|
LOGGER.error("[worldcheck] requested dimension '{}' is not loaded", target);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (ServerLevel level : server.getAllLevels()) {
|
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||||
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) {
|
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) {
|
||||||
return level;
|
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.Matter;
|
||||||
import art.arcane.volmlib.util.matter.MatterMarker;
|
import art.arcane.volmlib.util.matter.MatterMarker;
|
||||||
import art.arcane.volmlib.util.matter.slices.MarkerMatter;
|
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.core.BlockPos;
|
||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
import net.minecraft.server.level.ServerPlayer;
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
import net.minecraft.world.entity.Entity;
|
import net.minecraft.world.entity.Entity;
|
||||||
import net.minecraft.world.entity.LivingEntity;
|
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.Blocks;
|
||||||
import net.minecraft.world.level.block.state.BlockState;
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.level.chunk.LevelChunk;
|
||||||
import net.minecraft.world.phys.AABB;
|
import net.minecraft.world.phys.AABB;
|
||||||
|
|
||||||
import java.util.HashSet;
|
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_DRAIN_PER_TICK = 8;
|
||||||
private static final int MAX_INITIAL_RECOVERY_PER_PASS = 128;
|
private static final int MAX_INITIAL_RECOVERY_PER_PASS = 128;
|
||||||
private static final int MANTLE_WARMUP_QUEUE_CAPACITY = 256;
|
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 INITIAL_RECOVERY_INTERVAL_MS = 1_000L;
|
||||||
private static final long COUNT_INTERVAL_MS = 3_000L;
|
|
||||||
|
|
||||||
private final Engine engine;
|
private final Engine engine;
|
||||||
private final InitialSpawnQueue initialSpawnQueue;
|
private final InitialSpawnQueue initialSpawnQueue;
|
||||||
private final Set<Long> mantleWarmups;
|
private final Set<Long> mantleWarmups;
|
||||||
private final ThreadPoolExecutor mantleWarmupExecutor;
|
private final ThreadPoolExecutor mantleWarmupExecutor;
|
||||||
|
private final long[] ambientChunkSample = new long[AMBIENT_CHUNK_SAMPLE];
|
||||||
|
private int ambientChunkSampleSeen;
|
||||||
private long lastAmbientAt;
|
private long lastAmbientAt;
|
||||||
private long lastCountAt;
|
|
||||||
private long lastInitialRecoveryAt;
|
private long lastInitialRecoveryAt;
|
||||||
private boolean initialSpawnQueueClosed;
|
private boolean initialSpawnQueueClosed;
|
||||||
private boolean mantleWarmupExecutorStopped;
|
private boolean mantleWarmupExecutorStopped;
|
||||||
private boolean mantleWarmupsCleared;
|
private boolean mantleWarmupsCleared;
|
||||||
|
private boolean spawnStateMissingLogged;
|
||||||
private volatile boolean closed;
|
private volatile boolean closed;
|
||||||
|
private volatile boolean entityCountAvailable;
|
||||||
private volatile int cachedEntityCount;
|
private volatile int cachedEntityCount;
|
||||||
private volatile int cachedConsideredChunks;
|
private volatile int cachedConsideredChunks;
|
||||||
private volatile double cachedSaturation;
|
private volatile double cachedSaturation;
|
||||||
@@ -114,9 +120,16 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
EngineWorldManager worldManager = engine.getWorldManager();
|
EngineWorldManager worldManager = engine.getWorldManager();
|
||||||
if (worldManager instanceof ModdedWorldManager moddedWorldManager) {
|
if (!(worldManager instanceof ModdedWorldManager moddedWorldManager)) {
|
||||||
moddedWorldManager.initialSpawnQueue.offer(pack(chunkX, chunkZ));
|
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) {
|
public void serverTick(ServerLevel level) {
|
||||||
@@ -328,19 +341,23 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
long[] candidates = loadedChunkPositionsSnapshot(level);
|
int loadedChunks = sampleLoadedChunks(level);
|
||||||
refreshEntityCount(level, now, candidates.length);
|
refreshEntityCount(level, loadedChunks);
|
||||||
|
if (!entityCountAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (cachedSaturation > IrisSettings.get().getWorld().getTargetSpawnEntitiesPerChunk()) {
|
if (cachedSaturation > IrisSettings.get().getWorld().getTargetSpawnEntitiesPerChunk()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidates.length == 0) {
|
int sampled = Math.min(loadedChunks, ambientChunkSample.length);
|
||||||
|
if (sampled == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int spawnBuffer = RNG.r.i(2, 12);
|
int spawnBuffer = RNG.r.i(2, 12);
|
||||||
while (spawnBuffer-- > 0) {
|
while (spawnBuffer-- > 0) {
|
||||||
long key = candidates[RNG.r.nextInt(candidates.length)];
|
long key = ambientChunkSample[RNG.r.nextInt(sampled)];
|
||||||
try {
|
try {
|
||||||
ambientSpawnChunk(level, unpackX(key), unpackZ(key));
|
ambientSpawnChunk(level, unpackX(key), unpackZ(key));
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
@@ -673,29 +690,66 @@ public final class ModdedWorldManager implements EngineWorldManager {
|
|||||||
return (position.getX() >> 4) == chunkX && (position.getZ() >> 4) == chunkZ;
|
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;
|
cachedConsideredChunks = loadedChunks;
|
||||||
cachedSaturation = cachedEntityCount / (loadedChunks + 1.0) * 1.28;
|
NaturalSpawner.SpawnState spawnState = level.getChunkSource().getLastSpawnState();
|
||||||
if (now - lastCountAt < COUNT_INTERVAL_MS) {
|
if (spawnState == null) {
|
||||||
|
entityCountAvailable = false;
|
||||||
|
if (!spawnStateMissingLogged) {
|
||||||
|
spawnStateMissingLogged = true;
|
||||||
|
IrisLogging.warn("No spawn state for " + engine.getName() + " yet; ambient spawning held");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastCountAt = now;
|
|
||||||
|
|
||||||
int livingEntities = 0;
|
Object2IntMap<MobCategory> counts = spawnState.getMobCategoryCounts();
|
||||||
for (Entity entity : level.getAllEntities()) {
|
int mobs = 0;
|
||||||
if (entity instanceof LivingEntity && entity.isAlive()) {
|
for (MobCategory category : counts.keySet()) {
|
||||||
livingEntities++;
|
mobs += counts.getInt(category);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedEntityCount = livingEntities;
|
entityCountAvailable = true;
|
||||||
cachedSaturation = livingEntities / (loadedChunks + 1.0) * 1.28;
|
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());
|
* Reservoir sample (algorithm R) of the ready to send chunks into a fixed buffer. ambientTick only picks
|
||||||
level.getChunkSource().chunkMap.forEachReadyToSendChunk(chunk -> positions.add(chunk.getPos().pack()));
|
* up to 12 random chunks per pass, so materializing every loaded chunk position once per interval was
|
||||||
return positions.toLongArray();
|
* 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() {
|
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 org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.ServiceLoader;
|
import java.util.ServiceLoader;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
@@ -216,6 +219,44 @@ public final class ModdedCustomContentRegistry {
|
|||||||
return !PROVIDERS.isEmpty() || !CUSTOM_BLOCKS.isEmpty();
|
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
|
* 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
|
* 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.IrisModdedChunkGenerator;
|
||||||
import art.arcane.iris.modded.ModdedDimensionManager;
|
import art.arcane.iris.modded.ModdedDimensionManager;
|
||||||
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||||
|
import art.arcane.iris.modded.ModdedForcedDatapack;
|
||||||
import art.arcane.iris.modded.ModdedLoader;
|
import art.arcane.iris.modded.ModdedLoader;
|
||||||
import art.arcane.iris.modded.ModdedPackInstaller;
|
import art.arcane.iris.modded.ModdedPackInstaller;
|
||||||
import art.arcane.iris.modded.ModdedScheduler;
|
import art.arcane.iris.modded.ModdedScheduler;
|
||||||
|
import art.arcane.iris.modded.ModdedServerLevels;
|
||||||
import art.arcane.iris.modded.ModdedWorldgenIds;
|
import art.arcane.iris.modded.ModdedWorldgenIds;
|
||||||
import art.arcane.iris.spi.IrisLogging;
|
import art.arcane.iris.spi.IrisLogging;
|
||||||
import art.arcane.volmlib.util.collection.KMap;
|
import art.arcane.volmlib.util.collection.KMap;
|
||||||
@@ -121,6 +123,9 @@ public final class IrisModdedCommands {
|
|||||||
IrisSettings.invalidate();
|
IrisSettings.invalidate();
|
||||||
}
|
}
|
||||||
IrisSettings.get();
|
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();
|
boolean localeLoaded = IrisLanguage.reload();
|
||||||
if (localeLoaded) {
|
if (localeLoaded) {
|
||||||
ok(source, IrisLanguage.plain(
|
ok(source, IrisLanguage.plain(
|
||||||
@@ -178,10 +183,13 @@ public final class IrisModdedCommands {
|
|||||||
|
|
||||||
static int info(CommandSourceStack source, String filter) {
|
static int info(CommandSourceStack source, String filter) {
|
||||||
MinecraftServer server = source.getServer();
|
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<>();
|
List<String> lines = new ArrayList<>();
|
||||||
int total = 0;
|
int total = 0;
|
||||||
int iris = 0;
|
int iris = 0;
|
||||||
for (ServerLevel level : server.getAllLevels()) {
|
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||||
total++;
|
total++;
|
||||||
ChunkGenerator generator = level.getChunkSource().getGenerator();
|
ChunkGenerator generator = level.getChunkSource().getGenerator();
|
||||||
if (!(generator instanceof IrisModdedChunkGenerator irisGenerator)) {
|
if (!(generator instanceof IrisModdedChunkGenerator irisGenerator)) {
|
||||||
@@ -201,12 +209,14 @@ public final class IrisModdedCommands {
|
|||||||
+ " world=" + dimensionId + " (engine not started yet)");
|
+ " world=" + dimensionId + " (engine not started yet)");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
String featureStatus = irisGenerator.importedFeaturesStatus();
|
||||||
lines.add(irisIdentity + ": pack=" + engine.getDimension().getLoadKey()
|
lines.add(irisIdentity + ": pack=" + engine.getDimension().getLoadKey()
|
||||||
+ " world=" + dimensionId
|
+ " world=" + dimensionId
|
||||||
+ " seed=" + level.getSeed()
|
+ (showSeed ? " seed=" + level.getSeed() : "")
|
||||||
+ " height=" + engine.getMinHeight() + ".." + engine.getMaxHeight()
|
+ " height=" + engine.getMinHeight() + ".." + engine.getMaxHeight()
|
||||||
+ " generated=" + engine.getGenerated()
|
+ " 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)));
|
ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_LOADED_DIMENSIONS_IRIS, MessageArgument.untrusted("total", total), MessageArgument.untrusted("iris", iris)));
|
||||||
if (lines.isEmpty()) {
|
if (lines.isEmpty()) {
|
||||||
@@ -321,7 +331,7 @@ public final class IrisModdedCommands {
|
|||||||
|
|
||||||
private static int engineCount(MinecraftServer server) {
|
private static int engineCount(MinecraftServer server) {
|
||||||
int count = 0;
|
int count = 0;
|
||||||
for (ServerLevel level : server.getAllLevels()) {
|
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||||
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) {
|
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) {
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-2
@@ -231,13 +231,17 @@ final class ModdedCommandHelp {
|
|||||||
return 0;
|
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 totalPages = Math.max(1, (int) Math.ceil(entries.size() / (double) PAGE_SIZE));
|
||||||
int page = Math.max(0, Math.min(request.page(), totalPages - 1));
|
int page = Math.max(0, Math.min(request.page(), totalPages - 1));
|
||||||
int from = page * PAGE_SIZE;
|
int from = page * PAGE_SIZE;
|
||||||
int to = Math.min(entries.size(), from + PAGE_SIZE);
|
int to = Math.min(entries.size(), from + PAGE_SIZE);
|
||||||
|
|
||||||
ModdedCommandFeedback.clear(source);
|
|
||||||
|
|
||||||
sendHeader(source, request.section(), page, totalPages);
|
sendHeader(source, request.section(), page, totalPages);
|
||||||
if (!Commands.hasPermission(Commands.LEVEL_GAMEMASTERS).test(source)) {
|
if (!Commands.hasPermission(Commands.LEVEL_GAMEMASTERS).test(source)) {
|
||||||
ModdedCommandFeedback.send(source, opNotice());
|
ModdedCommandFeedback.send(source, opNotice());
|
||||||
@@ -252,6 +256,45 @@ final class ModdedCommandHelp {
|
|||||||
return 1;
|
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) {
|
private static void sendHeader(CommandSourceStack source, String path, int page, int totalPages) {
|
||||||
String title = path.isEmpty() ? "/iris" : "/iris " + path;
|
String title = path.isEmpty() ? "/iris" : "/iris " + path;
|
||||||
if (totalPages > 1) {
|
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.engine.framework.IrisStructureLocator;
|
||||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||||
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||||
|
import art.arcane.iris.modded.ModdedServerLevels;
|
||||||
import com.mojang.brigadier.context.CommandContext;
|
import com.mojang.brigadier.context.CommandContext;
|
||||||
import com.mojang.brigadier.suggestion.SuggestionProvider;
|
import com.mojang.brigadier.suggestion.SuggestionProvider;
|
||||||
import com.mojang.brigadier.suggestion.Suggestions;
|
import com.mojang.brigadier.suggestion.Suggestions;
|
||||||
@@ -181,7 +182,7 @@ final class ModdedCommandSuggestions {
|
|||||||
private static CompletableFuture<Suggestions> suggestDimensionNames(CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) {
|
private static CompletableFuture<Suggestions> suggestDimensionNames(CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) {
|
||||||
ModdedCommandFeedback.tab(context.getSource());
|
ModdedCommandFeedback.tab(context.getSource());
|
||||||
List<String> names = new ArrayList<>();
|
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) {
|
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) {
|
||||||
names.add(level.dimension().identifier().toString());
|
names.add(level.dimension().identifier().toString());
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-7
@@ -36,25 +36,42 @@ import java.util.function.Predicate;
|
|||||||
|
|
||||||
final class ModdedCommandTree {
|
final class ModdedCommandTree {
|
||||||
private static final Predicate<CommandSourceStack> GATE = Commands.hasPermission(Commands.LEVEL_GAMEMASTERS);
|
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() {
|
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() {
|
static LiteralArgumentBuilder<CommandSourceStack> rootTree() {
|
||||||
LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal("iris");
|
LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal("iris");
|
||||||
|
|
||||||
root.executes((CommandContext<CommandSourceStack> context) -> ModdedCommandHelp.send(context.getSource(), ""));
|
root.executes((CommandContext<CommandSourceStack> context) -> ModdedCommandHelp.send(context.getSource(), ""));
|
||||||
root.then(helpTree());
|
root.then(helpTree());
|
||||||
|
|
||||||
root.then(Commands.literal("version")
|
root.then(Commands.literal("version").requires(READ_ONLY)
|
||||||
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.version(context.getSource())));
|
.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))
|
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), null))
|
||||||
.then(Commands.argument("dimension", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.DIMENSION_NAMES)
|
.then(Commands.argument("dimension", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.DIMENSION_NAMES)
|
||||||
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), StringArgumentType.getString(context, "dimension")))));
|
.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("teleport"));
|
||||||
root.then(teleportTree("tp"));
|
root.then(teleportTree("tp"));
|
||||||
@@ -69,9 +86,9 @@ final class ModdedCommandTree {
|
|||||||
|
|
||||||
root.then(Commands.literal("reload").requires(GATE)
|
root.then(Commands.literal("reload").requires(GATE)
|
||||||
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.reload(context.getSource())));
|
.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())));
|
.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)));
|
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), null)));
|
||||||
root.then(Commands.literal("accesslist").requires(GATE)
|
root.then(Commands.literal("accesslist").requires(GATE)
|
||||||
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), null)));
|
.executes((CommandContext<CommandSourceStack> context) -> IrisModdedCommands.info(context.getSource(), null)));
|
||||||
@@ -162,7 +179,7 @@ final class ModdedCommandTree {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static LiteralArgumentBuilder<CommandSourceStack> helpTree() {
|
private static LiteralArgumentBuilder<CommandSourceStack> helpTree() {
|
||||||
return Commands.literal("help")
|
return Commands.literal("help").requires(READ_ONLY)
|
||||||
.executes((CommandContext<CommandSourceStack> context) -> ModdedCommandHelp.send(context.getSource(), ""))
|
.executes((CommandContext<CommandSourceStack> context) -> ModdedCommandHelp.send(context.getSource(), ""))
|
||||||
.then(Commands.argument("section", StringArgumentType.greedyString())
|
.then(Commands.argument("section", StringArgumentType.greedyString())
|
||||||
.executes((CommandContext<CommandSourceStack> context) -> ModdedCommandHelp.send(context.getSource(), StringArgumentType.getString(context, "section"))));
|
.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) {
|
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()));
|
.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.framework.Engine;
|
||||||
import art.arcane.iris.engine.object.IrisDimension;
|
import art.arcane.iris.engine.object.IrisDimension;
|
||||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||||
|
import art.arcane.iris.modded.ModdedServerLevels;
|
||||||
import art.arcane.volmlib.util.collection.KList;
|
import art.arcane.volmlib.util.collection.KList;
|
||||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||||
@@ -107,7 +108,7 @@ public final class ModdedDatapackCommands {
|
|||||||
MinecraftServer server = source.getServer();
|
MinecraftServer server = source.getServer();
|
||||||
int irisLevels = 0;
|
int irisLevels = 0;
|
||||||
int mismatches = 0;
|
int mismatches = 0;
|
||||||
for (ServerLevel level : server.getAllLevels()) {
|
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||||
if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator irisGenerator)) {
|
if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator irisGenerator)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -150,7 +151,7 @@ public final class ModdedDatapackCommands {
|
|||||||
private static int install(CommandSourceStack source) {
|
private static int install(CommandSourceStack source) {
|
||||||
MinecraftServer server = source.getServer();
|
MinecraftServer server = source.getServer();
|
||||||
List<String> written = new ArrayList<>();
|
List<String> written = new ArrayList<>();
|
||||||
for (ServerLevel level : server.getAllLevels()) {
|
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||||
if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator irisGenerator)) {
|
if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator irisGenerator)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-3
@@ -26,6 +26,8 @@ import art.arcane.iris.modded.ModdedBlockResolution;
|
|||||||
import art.arcane.iris.modded.ModdedBlockState;
|
import art.arcane.iris.modded.ModdedBlockState;
|
||||||
import art.arcane.iris.modded.ModdedTileData;
|
import art.arcane.iris.modded.ModdedTileData;
|
||||||
import art.arcane.iris.spi.PlatformBlockState;
|
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.core.BlockPos;
|
||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
import net.minecraft.world.level.block.Block;
|
import net.minecraft.world.level.block.Block;
|
||||||
@@ -43,6 +45,7 @@ import java.util.Map;
|
|||||||
|
|
||||||
final class ModdedObjectPlacer implements IObjectPlacer {
|
final class ModdedObjectPlacer implements IObjectPlacer {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||||
|
private static final int DEFAULT_FLUID_HEIGHT = 63;
|
||||||
|
|
||||||
private final ServerLevel level;
|
private final ServerLevel level;
|
||||||
private final Engine engine;
|
private final Engine engine;
|
||||||
@@ -116,9 +119,21 @@ final class ModdedObjectPlacer implements IObjectPlacer {
|
|||||||
return false;
|
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
|
@Override
|
||||||
public boolean isCarved(int x, int y, int z) {
|
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
|
@Override
|
||||||
@@ -126,14 +141,24 @@ final class ModdedObjectPlacer implements IObjectPlacer {
|
|||||||
return ModdedBlockResolution.isSolid(level.getBlockState(new BlockPos(x, y, z)));
|
return ModdedBlockResolution.isSolid(level.getBlockState(new BlockPos(x, y, z)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Engine height stream against the dimension fluid height, both engine relative, so no shift here. Needs a
|
||||||
|
* ready complex; the placer also runs from commands against levels that never bound an engine.
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public boolean isUnderwater(int x, int z) {
|
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
|
@Override
|
||||||
public int getFluidHeight() {
|
public int getFluidHeight() {
|
||||||
return 63;
|
return engine == null
|
||||||
|
? DEFAULT_FLUID_HEIGHT
|
||||||
|
: engine.getMinHeight() + engine.getDimension().getFluidHeight();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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.PregenMantleBackpressure;
|
||||||
import art.arcane.iris.core.pregenerator.PregeneratorMethod;
|
import art.arcane.iris.core.pregenerator.PregeneratorMethod;
|
||||||
import art.arcane.iris.engine.framework.Engine;
|
import art.arcane.iris.engine.framework.Engine;
|
||||||
|
import art.arcane.iris.modded.ModdedGenPool;
|
||||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||||
import net.minecraft.server.MinecraftServer;
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.dedicated.DedicatedServer;
|
||||||
import net.minecraft.server.level.ChunkResult;
|
import net.minecraft.server.level.ChunkResult;
|
||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
import net.minecraft.server.level.TicketType;
|
import net.minecraft.server.level.TicketType;
|
||||||
@@ -32,9 +34,9 @@ import net.minecraft.world.level.ChunkPos;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.lang.reflect.Field;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
import java.util.concurrent.ForkJoinPool;
|
import java.util.concurrent.ForkJoinPool;
|
||||||
import java.util.concurrent.Semaphore;
|
import java.util.concurrent.Semaphore;
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
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 ADAPTIVE_RECOVERY_INTERVAL = 64L;
|
||||||
private static final long FINAL_SAVE_TIMEOUT_MILLIS = 10_000L;
|
private static final long FINAL_SAVE_TIMEOUT_MILLIS = 10_000L;
|
||||||
private static final long FINAL_SAVE_POLL_MILLIS = 50L;
|
private static final long FINAL_SAVE_POLL_MILLIS = 50L;
|
||||||
private static final boolean PARALLEL_CHUNK_SYSTEM = detectParallelChunkSystem();
|
|
||||||
|
|
||||||
private final ServerLevel level;
|
private final ServerLevel level;
|
||||||
private final Engine engine;
|
private final Engine engine;
|
||||||
@@ -70,8 +71,10 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
private final AtomicBoolean finalSaveDeferred = new AtomicBoolean(false);
|
private final AtomicBoolean finalSaveDeferred = new AtomicBoolean(false);
|
||||||
private final AtomicBoolean finalSaveCompleted = new AtomicBoolean(false);
|
private final AtomicBoolean finalSaveCompleted = new AtomicBoolean(false);
|
||||||
private final AtomicReference<FinalSaveRequest> queuedFinalSave = new AtomicReference<>();
|
private final AtomicReference<FinalSaveRequest> queuedFinalSave = new AtomicReference<>();
|
||||||
|
private final AtomicBoolean stallHintLogged = new AtomicBoolean(false);
|
||||||
private final int timeoutSeconds;
|
private final int timeoutSeconds;
|
||||||
private final PregenMantleBackpressure backpressure;
|
private final PregenMantleBackpressure backpressure;
|
||||||
|
private final PauseWhenEmptyGuard pauseGuard;
|
||||||
|
|
||||||
public ModdedPregenMethod(ServerLevel level, Engine engine) {
|
public ModdedPregenMethod(ServerLevel level, Engine engine) {
|
||||||
this(level, engine, false);
|
this(level, engine, false);
|
||||||
@@ -81,6 +84,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
this.level = level;
|
this.level = level;
|
||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
this.sync = sync;
|
this.sync = sync;
|
||||||
|
this.pauseGuard = new PauseWhenEmptyGuard(level.getServer());
|
||||||
IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen();
|
IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen();
|
||||||
this.maxInFlight = Math.max(8, pregen.getModdedPregenInFlight());
|
this.maxInFlight = Math.max(8, pregen.getModdedPregenInFlight());
|
||||||
this.minInFlight = Math.max(4, Math.min(16, maxInFlight / 4));
|
this.minInFlight = Math.max(4, Math.min(16, maxInFlight / 4));
|
||||||
@@ -99,33 +103,38 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void init() {
|
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(),
|
level.dimension().identifier(),
|
||||||
sync ? "sync" : "async",
|
sync ? "sync" : "async",
|
||||||
sync ? 1 : maxInFlight,
|
sync ? 1 : maxInFlight,
|
||||||
timeoutSeconds,
|
timeoutSeconds,
|
||||||
describeWorkerPool(),
|
describeWorkerPool(),
|
||||||
PARALLEL_CHUNK_SYSTEM ? "yes" : "no");
|
ModdedGenPool.describeChunkSystem());
|
||||||
if (!sync && !PARALLEL_CHUNK_SYSTEM) {
|
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.");
|
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
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
if (!sync) {
|
try {
|
||||||
try {
|
if (!sync) {
|
||||||
semaphore.tryAcquire(maxInFlight, 5, TimeUnit.SECONDS);
|
try {
|
||||||
} catch (InterruptedException e) {
|
semaphore.tryAcquire(maxInFlight, 5, TimeUnit.SECONDS);
|
||||||
Thread.currentThread().interrupt();
|
} 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
|
@Override
|
||||||
@@ -337,6 +346,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
} catch (TimeoutException | ExecutionException e) {
|
} catch (TimeoutException | ExecutionException e) {
|
||||||
|
if (e instanceof TimeoutException) {
|
||||||
|
noteStallHint();
|
||||||
|
}
|
||||||
LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, e.toString());
|
LOGGER.warn("Iris pregen chunk {},{} failed: {}", x, z, e.toString());
|
||||||
listener.onChunkFailed(x, z);
|
listener.onChunkFailed(x, z);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -413,11 +425,22 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void onTimeout() {
|
private void onTimeout() {
|
||||||
|
noteStallHint();
|
||||||
if (timeoutStreak.incrementAndGet() % ADAPTIVE_TIMEOUT_STEP == 0) {
|
if (timeoutStreak.incrementAndGet() % ADAPTIVE_TIMEOUT_STEP == 0) {
|
||||||
adjustAdaptiveLimit(-1);
|
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() {
|
private void onSuccess() {
|
||||||
int streak = timeoutStreak.get();
|
int streak = timeoutStreak.get();
|
||||||
if (streak > 0) {
|
if (streak > 0) {
|
||||||
@@ -455,56 +478,171 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
|
|||||||
private void cleanupMantleChunk(int x, int z) {
|
private void cleanupMantleChunk(int x, int z) {
|
||||||
try {
|
try {
|
||||||
engine.getMantle().forceCleanupChunk(x, z);
|
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() {
|
private String describeWorkerPool() {
|
||||||
try {
|
Executor exec = level.getServer().executor;
|
||||||
Field field = MinecraftServer.class.getDeclaredField("executor");
|
if (exec == null) {
|
||||||
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) {
|
|
||||||
return "unknown";
|
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) {
|
private static Throwable unwrap(Throwable error) {
|
||||||
return error != null && error.getCause() != null ? error.getCause() : 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
|
@Override
|
||||||
public Mantle getMantle() {
|
public Mantle getMantle() {
|
||||||
return engine.getMantle().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 static final class FinalSaveRequest {
|
||||||
private final CompletableFuture<Void> completion = new CompletableFuture<>();
|
private final CompletableFuture<Void> completion = new CompletableFuture<>();
|
||||||
private final AtomicBoolean active = new AtomicBoolean(true);
|
private final AtomicBoolean active = new AtomicBoolean(true);
|
||||||
|
|||||||
+32
-2
@@ -58,6 +58,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.Semaphore;
|
import java.util.concurrent.Semaphore;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
@@ -66,6 +67,8 @@ import art.arcane.iris.core.localization.ModdedCommandMessages;
|
|||||||
public final class ModdedRegen {
|
public final class ModdedRegen {
|
||||||
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
|
||||||
private static final int APPLY_AHEAD = 8;
|
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 static final AtomicBoolean ACTIVE = new AtomicBoolean(false);
|
||||||
|
|
||||||
private final CommandSourceStack source;
|
private final CommandSourceStack source;
|
||||||
@@ -139,6 +142,7 @@ public final class ModdedRegen {
|
|||||||
private int regenerate(List<int[]> targets) throws InterruptedException {
|
private int regenerate(List<int[]> targets) throws InterruptedException {
|
||||||
Semaphore inFlight = new Semaphore(APPLY_AHEAD);
|
Semaphore inFlight = new Semaphore(APPLY_AHEAD);
|
||||||
CountDownLatch allApplied = new CountDownLatch(targets.size());
|
CountDownLatch allApplied = new CountDownLatch(targets.size());
|
||||||
|
AtomicBoolean aborted = new AtomicBoolean(false);
|
||||||
AtomicInteger completed = new AtomicInteger();
|
AtomicInteger completed = new AtomicInteger();
|
||||||
AtomicInteger applied = new AtomicInteger();
|
AtomicInteger applied = new AtomicInteger();
|
||||||
int total = targets.size();
|
int total = targets.size();
|
||||||
@@ -149,9 +153,21 @@ public final class ModdedRegen {
|
|||||||
for (int[] target : targets) {
|
for (int[] target : targets) {
|
||||||
int chunkX = target[0];
|
int chunkX = target[0];
|
||||||
int chunkZ = target[1];
|
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(() -> {
|
MultiBurst.burst.lazy(() -> {
|
||||||
long chunkStart = M.ms();
|
long chunkStart = M.ms();
|
||||||
|
if (aborted.get()) {
|
||||||
|
completed.incrementAndGet();
|
||||||
|
inFlight.release();
|
||||||
|
allApplied.countDown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
ModdedBlockBuffer blocks = new ModdedBlockBuffer(height, air);
|
ModdedBlockBuffer blocks = new ModdedBlockBuffer(height, air);
|
||||||
Hunk<PlatformBiome> biomes = Hunk.newArrayHunk(16, height, 16);
|
Hunk<PlatformBiome> biomes = Hunk.newArrayHunk(16, height, 16);
|
||||||
try {
|
try {
|
||||||
@@ -167,6 +183,9 @@ public final class ModdedRegen {
|
|||||||
server.execute(() -> {
|
server.execute(() -> {
|
||||||
boolean success = false;
|
boolean success = false;
|
||||||
try {
|
try {
|
||||||
|
if (aborted.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
apply(chunkX, chunkZ, blocks, biomes);
|
apply(chunkX, chunkZ, blocks, biomes);
|
||||||
success = true;
|
success = true;
|
||||||
applied.incrementAndGet();
|
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();
|
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.ModdedModConfig;
|
||||||
import art.arcane.iris.modded.ModdedPackInstaller;
|
import art.arcane.iris.modded.ModdedPackInstaller;
|
||||||
import art.arcane.iris.modded.ModdedPrimaryWorldRouter;
|
import art.arcane.iris.modded.ModdedPrimaryWorldRouter;
|
||||||
|
import art.arcane.iris.modded.ModdedServerLevels;
|
||||||
import art.arcane.iris.modded.ModdedStartup;
|
import art.arcane.iris.modded.ModdedStartup;
|
||||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||||
@@ -436,7 +437,7 @@ public final class ModdedWorldCommands {
|
|||||||
private static int status(CommandSourceStack source) {
|
private static int status(CommandSourceStack source) {
|
||||||
MinecraftServer server = source.getServer();
|
MinecraftServer server = source.getServer();
|
||||||
int loaded = 0;
|
int loaded = 0;
|
||||||
for (ServerLevel level : server.getAllLevels()) {
|
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||||
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator) {
|
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator) {
|
||||||
loaded++;
|
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())));
|
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) {
|
private static List<String> loadedIrisDimensions(MinecraftServer server) {
|
||||||
List<String> dimensions = new ArrayList<>();
|
List<String> dimensions = new ArrayList<>();
|
||||||
for (ServerLevel level : server.getAllLevels()) {
|
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||||
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) {
|
if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator) {
|
||||||
dimensions.add(level.dimension().identifier().toString());
|
dimensions.add(level.dimension().identifier().toString());
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -1,6 +1,7 @@
|
|||||||
package art.arcane.iris.modded.mixin;
|
package art.arcane.iris.modded.mixin;
|
||||||
|
|
||||||
import art.arcane.iris.modded.ModdedEntityPersistence;
|
import art.arcane.iris.modded.ModdedEntityPersistence;
|
||||||
|
import art.arcane.iris.modded.ModdedMixinFlags;
|
||||||
import net.minecraft.world.entity.Entity;
|
import net.minecraft.world.entity.Entity;
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
import org.spongepowered.asm.mixin.injection.At;
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
@@ -11,6 +12,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
|||||||
public abstract class EntityPersistenceMixin {
|
public abstract class EntityPersistenceMixin {
|
||||||
@Inject(method = "shouldBeSaved", at = @At("RETURN"), cancellable = true)
|
@Inject(method = "shouldBeSaved", at = @At("RETURN"), cancellable = true)
|
||||||
private void iris$applyGeneratedPersistence(CallbackInfoReturnable<Boolean> info) {
|
private void iris$applyGeneratedPersistence(CallbackInfoReturnable<Boolean> info) {
|
||||||
|
ModdedMixinFlags.markEntityPersistence();
|
||||||
Entity entity = (Entity) (Object) this;
|
Entity entity = (Entity) (Object) this;
|
||||||
info.setReturnValue(ModdedEntityPersistence.shouldSave(entity, info.getReturnValue()));
|
info.setReturnValue(ModdedEntityPersistence.shouldSave(entity, info.getReturnValue()));
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -1,6 +1,7 @@
|
|||||||
package art.arcane.iris.modded.mixin;
|
package art.arcane.iris.modded.mixin;
|
||||||
|
|
||||||
import art.arcane.iris.modded.ModdedDeathLoot;
|
import art.arcane.iris.modded.ModdedDeathLoot;
|
||||||
|
import art.arcane.iris.modded.ModdedMixinFlags;
|
||||||
import net.minecraft.server.level.ServerLevel;
|
import net.minecraft.server.level.ServerLevel;
|
||||||
import net.minecraft.world.damagesource.DamageSource;
|
import net.minecraft.world.damagesource.DamageSource;
|
||||||
import net.minecraft.world.entity.LivingEntity;
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
@@ -13,6 +14,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
|||||||
public abstract class LivingEntityLootMixin {
|
public abstract class LivingEntityLootMixin {
|
||||||
@Inject(method = "dropFromLootTable(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/damagesource/DamageSource;Z)V", at = @At("HEAD"), cancellable = true)
|
@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) {
|
private void iris$replaceBaseLoot(ServerLevel level, DamageSource damageSource, boolean playerKilled, CallbackInfo info) {
|
||||||
|
ModdedMixinFlags.markLivingEntityLoot();
|
||||||
if (ModdedDeathLoot.replaceBaseLoot((LivingEntity) (Object) this)) {
|
if (ModdedDeathLoot.replaceBaseLoot((LivingEntity) (Object) this)) {
|
||||||
info.cancel();
|
info.cancel();
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -1,6 +1,7 @@
|
|||||||
package art.arcane.iris.modded.mixin;
|
package art.arcane.iris.modded.mixin;
|
||||||
|
|
||||||
import art.arcane.iris.modded.ModdedEntityAwareness;
|
import art.arcane.iris.modded.ModdedEntityAwareness;
|
||||||
|
import art.arcane.iris.modded.ModdedMixinFlags;
|
||||||
import net.minecraft.world.entity.Mob;
|
import net.minecraft.world.entity.Mob;
|
||||||
import net.minecraft.world.entity.ai.goal.FloatGoal;
|
import net.minecraft.world.entity.ai.goal.FloatGoal;
|
||||||
import net.minecraft.world.entity.ai.goal.WrappedGoal;
|
import net.minecraft.world.entity.ai.goal.WrappedGoal;
|
||||||
@@ -21,6 +22,7 @@ public abstract class MobAwarenessMixin {
|
|||||||
shift = At.Shift.AFTER),
|
shift = At.Shift.AFTER),
|
||||||
cancellable = true)
|
cancellable = true)
|
||||||
private void iris$tickUnawareMob(CallbackInfo info) {
|
private void iris$tickUnawareMob(CallbackInfo info) {
|
||||||
|
ModdedMixinFlags.markMobAwareness();
|
||||||
Mob mob = (Mob) (Object) this;
|
Mob mob = (Mob) (Object) this;
|
||||||
if (ModdedEntityAwareness.isAware(mob)) {
|
if (ModdedEntityAwareness.isAware(mob)) {
|
||||||
return;
|
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.IrisModdedChunkGenerator;
|
||||||
import art.arcane.iris.modded.ModdedBlockResolution;
|
import art.arcane.iris.modded.ModdedBlockResolution;
|
||||||
import art.arcane.iris.modded.ModdedLootApplier;
|
import art.arcane.iris.modded.ModdedLootApplier;
|
||||||
|
import art.arcane.iris.modded.ModdedServerLevels;
|
||||||
import art.arcane.iris.modded.ModdedTileData;
|
import art.arcane.iris.modded.ModdedTileData;
|
||||||
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
|
import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
|
||||||
import art.arcane.iris.spi.IrisLogging;
|
import art.arcane.iris.spi.IrisLogging;
|
||||||
@@ -100,7 +101,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (ServerLevel level : server.getAllLevels()) {
|
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||||
if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator)) {
|
if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator)) {
|
||||||
continue;
|
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.Engine;
|
||||||
import art.arcane.iris.engine.framework.EngineWorldManager;
|
import art.arcane.iris.engine.framework.EngineWorldManager;
|
||||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||||
|
import art.arcane.iris.modded.ModdedServerLevels;
|
||||||
import art.arcane.iris.modded.ModdedWorldManager;
|
import art.arcane.iris.modded.ModdedWorldManager;
|
||||||
import art.arcane.iris.spi.IrisLogging;
|
import art.arcane.iris.spi.IrisLogging;
|
||||||
import net.minecraft.server.MinecraftServer;
|
import net.minecraft.server.MinecraftServer;
|
||||||
@@ -37,7 +38,7 @@ public final class ModdedEntitySpawnService implements ModdedTickableService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onServerTick(MinecraftServer server) {
|
public void onServerTick(MinecraftServer server) {
|
||||||
for (ServerLevel level : server.getAllLevels()) {
|
for (ServerLevel level : ModdedServerLevels.levels(server)) {
|
||||||
if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator)) {
|
if (!(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -2,7 +2,7 @@
|
|||||||
"required": true,
|
"required": true,
|
||||||
"minVersion": "0.8",
|
"minVersion": "0.8",
|
||||||
"package": "art.arcane.iris.client.mixin",
|
"package": "art.arcane.iris.client.mixin",
|
||||||
"compatibilityLevel": "JAVA_25",
|
"compatibilityLevel": "JAVA_21",
|
||||||
"client": [
|
"client": [
|
||||||
"IrisWorldOpenFlowsMixin",
|
"IrisWorldOpenFlowsMixin",
|
||||||
"IrisWorldTypeEntryMixin"
|
"IrisWorldTypeEntryMixin"
|
||||||
|
|||||||
+51
@@ -53,5 +53,56 @@ public class IrisClientSessionTest {
|
|||||||
IrisProtocol.PROTOCOL_VERSION + 1, 0L, "Iris", true));
|
IrisProtocol.PROTOCOL_VERSION + 1, 0L, "Iris", true));
|
||||||
|
|
||||||
assertEquals(IrisClientSession.State.INCOMPATIBLE, session.state());
|
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());
|
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
|
@Test
|
||||||
public void expiredInFlightEntryCannotRetry() {
|
public void expiredInFlightEntryCannotRetry() {
|
||||||
AtomicLong now = new AtomicLong();
|
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.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
import static org.junit.Assert.assertThrows;
|
import static org.junit.Assert.assertThrows;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
public class ModdedDimensionRegistryStoreTest {
|
public class ModdedDimensionRegistryStoreTest {
|
||||||
@Test
|
@Test
|
||||||
@@ -84,4 +88,53 @@ public class ModdedDimensionRegistryStoreTest {
|
|||||||
Files.deleteIfExists(root);
|
Files.deleteIfExists(root);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void startupLoadQuarantinesACorruptRegistryInsteadOfFailingBoot() throws IOException {
|
||||||
|
Path root = Files.createTempDirectory("iris-dimension-registry-corrupt-boot");
|
||||||
|
Path file = root.resolve("iris-dimensions.json");
|
||||||
|
try {
|
||||||
|
Files.writeString(file, "{\"dimensions\":[{\"id\":\"iris:lost\",", StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
assertEquals(List.of(), ModdedDimensionRegistryStore.loadForStartup(file));
|
||||||
|
assertFalse(Files.exists(file));
|
||||||
|
|
||||||
|
try (Stream<Path> entries = Files.list(root)) {
|
||||||
|
assertTrue(entries.anyMatch((Path entry) ->
|
||||||
|
entry.getFileName().toString().startsWith("iris-dimensions.json.broken-")));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
deleteTree(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void startupLoadReturnsHealthyEntriesUntouched() throws IOException {
|
||||||
|
Path root = Files.createTempDirectory("iris-dimension-registry-healthy-boot");
|
||||||
|
Path file = root.resolve("iris-dimensions.json");
|
||||||
|
try {
|
||||||
|
List<ModdedDimensionRegistryStore.PersistentDimension> expected = List.of(
|
||||||
|
new ModdedDimensionRegistryStore.PersistentDimension(
|
||||||
|
"iris:first", "overworld", "overworld", 42L));
|
||||||
|
ModdedDimensionRegistryStore.write(file, expected);
|
||||||
|
|
||||||
|
assertEquals(expected, ModdedDimensionRegistryStore.loadForStartup(file));
|
||||||
|
assertTrue(Files.exists(file));
|
||||||
|
} finally {
|
||||||
|
deleteTree(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deleteTree(Path root) throws IOException {
|
||||||
|
if (!Files.exists(root)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Path> entries;
|
||||||
|
try (Stream<Path> walk = Files.walk(root)) {
|
||||||
|
entries = walk.sorted(Comparator.reverseOrder()).toList();
|
||||||
|
}
|
||||||
|
for (Path entry : entries) {
|
||||||
|
Files.deleteIfExists(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user