Compare commits

...
Author SHA1 Message Date
Brian Neumann-Fopiano a521109a6a Updated release version
- Iris version is 4.3.3-26.2
- Loader build defaults use the current release version
2026-10-12 14:47:39 -04:00
Brian Neumann-Fopiano a9f5145c0f Fixed Iris artifact builds
- Loader jars exclude Bukkit dependency bootstrap classes
- Artifact verification rejects bootstrap references and executable payloads
- Verifier tests cover nested jars and bundled compression libraries
- Local VolmLib native setup finishes before Iris native setup
2026-10-12 11:34:41 -04:00
Brian Neumann-Fopiano e3673afb1e Fixed mining and timeout settings
- Pending biome reads no longer cancel block breaking
- Custom loot resolves saved biome history after accepted breaks
- Native drops survive failed reads and runtime teardown without duplication
- Tree felling captures the original tool for fallback drops
- Modded block loot resolves one historical biome context
- Timeout settings persist atomically through the actual server configuration paths
- Tests cover deferred loot, teardown and timeout persistence
2026-10-12 09:27:26 -04:00
Brian Neumann-Fopiano 2145e90b1c Rebase 2026-10-11 10:35:59 -04:00
SwiftSwamp d480abad50 Added shared update notices
- Iris joins shared Volmit update checks and reports
- The iris.update permission controls update visibility
- general.updateNotifications defaults to enabled
- Accepted settings changes reconfigure update checks
- Shutdown removes the settings listener and closes update checks
- Tests cover persisted opt-outs and notifier lifecycle
2026-10-09 09:30:12 -06:00
Brian Neumann-Fopiano be0cd567ed V+
- Iris version is 4.3.2-26.2
- Loader build fallbacks use the current release version
2026-10-08 17:17:12 -04:00
Brian Neumann-Fopiano ce7872f667 Removing scheduler
- not needed
2026-10-08 13:14:35 -04:00
Brian Neumann-Fopiano cfa274825f Refine terrain and pregeneration
- Add coherent river width variation and shape source and terminal pools.
- Preserve separated hydrology intervals when checking river connectivity.
- Reduce footprint sorting, engine context lookup, and terrain blending work.
- Reuse modded snapshot validation after content and registry checks.
- Explain production pack update staging and restart requirements.
- Retain pregeneration map updates before its desktop window opens.
- Improve pregeneration layout, coordinate hover, keyboard controls, and error details.
- Add the new desktop labels to every supported language catalog.
- Add the buildPsychoLT entry point for verified platform outputs.
- Cover terrain, validation, and desktop behavior with regression tests.
2026-10-07 10:25:41 -04:00
Brian Neumann-Fopiano e72189adc7 Adopt cooperative reload cleanup
- Return cleanup completion from the committed Iris reload callback
2026-10-07 10:24:59 -04:00
Brian Neumann-Fopiano 7f5ce70f49 Improve generation and lifecycle safety
- Reduce terrain, noise, hydrology, and decorator sampling overhead.
- Reduce object transaction allocations while preserving rollback behavior.
- Optimize structure placement and cave anchor sampling.
- Parallelize Studio cache warmup and protect world entry and hotload recovery.
- Validate pack fingerprints and publish pack replacements atomically.
- Notify modded operators about outdated packs without synchronizing pack data.
- Harden world creation, loading, evacuation, and teleport failure recovery.
- Coalesce queued region flushes while preserving chunk save ordering.
- Remove obsolete mantle, pack, and cave planning code.
- Add regression tests and reusable gameplay fixtures.
2026-10-06 20:51:28 -04:00
Brian Neumann-Fopiano a2bcd7a302 V+
- Bump Iris from 4.3.0-26.2 to 4.3.1-26.2.
- Align Fabric, Forge, and NeoForge version defaults with the release.
2026-10-05 16:48:32 -04:00
Brian Neumann-Fopiano 7226fc4400 Refine organic cave generation
- Add seeded cave surface variation and rounded basin undersides.
- Keep dry cave intersections open while preserving fluid seals.
- Apply room biome decorators and preserve their floor substrates.
- Allow ceiling growth above reserved walking clearance.
- Restrict feature placement by region across live and saved generation.
- Cover cave geometry, placement, decoration, and regional filtering with regression tests.
2026-10-05 16:22:55 -04:00
Brian Neumann-Fopiano c479e94c49 Fix biome names and conditions
- Register readable client biome IDs while preserving saved registry identities.
- Audit the modded registry naming mixin.
- Return false from MythicMobs conditions while saved biome data loads.
- Cover biome naming and MythicMobs loading behavior with regression tests.
2026-10-04 20:10:26 -04:00
Brian Neumann-Fopiano ee5aaec87c Fixed Structure Crash
- Don't carash anymore when you disable structures in the server.properties.
2026-10-04 15:32:54 -04:00
Brian Neumann-Fopiano 65ae3f1b45 Cleanup 2026-10-04 15:17:12 -04:00
Brian Neumann-Fopiano a6149b27c9 Adapter patches
(tests)
2026-10-04 13:08:41 -04:00
Brian Neumann-Fopiano 4b4b418c61 Merge Iris generation changes
- Combine authored subterrain features with native CPU generation.
- Verify dimension JSON and schemas preserve subterrain configuration.
2026-10-04 10:11:44 -04:00
Brian Neumann-Fopiano 8d5cf43139 Enable native noise generation
- Enable exact Rust Billow sampling and report the selected backend.
- Add generation parity probes and native comparison tooling.
2026-10-04 09:59:58 -04:00
Brian Neumann-Fopiano 5858eb366d Add subterrain features
- Generate authored underground rooms, materials, fluids, and formations.
- Preserve underground biome ownership across generation and saved terrain.
- Add subterrain searches and regression coverage.
2026-10-04 09:59:57 -04:00
Brian Neumann-Fopiano be0d3b0156 Fix seeded entity spawning
- Derive initial chunk and marker populations from stable location seeds.
- Persist initial spawn completion after region-owned application.
- Seed entity loot and attribute modifier identities.
- Cover spawning, reload completion, and registry fixtures with regression tests.
2026-10-04 06:14:46 -04:00
463 changed files with 22160 additions and 5921 deletions

No files matched your search

+5
View File
@@ -76,3 +76,8 @@ BUILDS/
.compatwt/ .compatwt/
.worktrees/ .worktrees/
/VolmLib/
/docs/
/.perf/
/.codegraph/
@@ -14,6 +14,7 @@ import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap; import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.LevelHeightAccessor; import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.Heightmap;
import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@@ -45,6 +46,11 @@ public final class IrisChunkGeneratorHeightCacheTest {
NativeBlockTags.bindHeightmapFixtures(); NativeBlockTags.bindHeightmapFixtures();
} }
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test @Test
public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception { public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception {
Fixture fixture = fixture(); Fixture fixture = fixture();
@@ -20,6 +20,7 @@ import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.RotatedPillarBlock; import net.minecraft.world.level.block.RotatedPillarBlock;
import net.minecraft.world.level.block.LiquidBlock; import net.minecraft.world.level.block.LiquidBlock;
import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.Heightmap;
import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@@ -46,6 +47,11 @@ public final class NativeTransitionColumnTest {
NativeBlockTags.bindHeightmapFixtures(); NativeBlockTags.bindHeightmapFixtures();
} }
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test @Test
public void customKeysUseCarrierForNativeColumnsAndHeightmaps() { public void customKeysUseCarrierForNativeColumnsAndHeightmaps() {
String key = "itemsadder:rocks/ruby_ore"; String key = "itemsadder:rocks/ruby_ore";
@@ -1,21 +1,30 @@
package art.arcane.iris.nativegen.v26_3_R1; package art.arcane.iris.nativegen.v26_3_R1;
import net.minecraft.core.Holder; import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
import net.minecraft.tags.BlockTags; import net.minecraft.tags.BlockTags;
import net.minecraft.tags.TagLoader; import net.minecraft.tags.TagLoader;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
public final class NativeBlockTags { public final class NativeBlockTags {
private static Map<TagKey<Block>, List<Holder<Block>>> originalTags;
private NativeBlockTags() { private NativeBlockTags() {
} }
public static void bindHeightmapFixtures() { public static void bindHeightmapFixtures() {
originalTags = new HashMap<>();
for (HolderSet.Named<Block> tag : BuiltInRegistries.BLOCK.getTags().toList()) {
originalTags.put(tag.key(), tag.stream().toList());
}
List<Holder<Block>> solid = List.of(Blocks.STONE.builtInRegistryHolder(), List<Holder<Block>> solid = List.of(Blocks.STONE.builtInRegistryHolder(),
Blocks.OAK_LOG.builtInRegistryHolder(), Blocks.SPRUCE_LOG.builtInRegistryHolder()); Blocks.OAK_LOG.builtInRegistryHolder(), Blocks.SPRUCE_LOG.builtInRegistryHolder());
List<Holder<Block>> canopy = List.of(Blocks.STONE.builtInRegistryHolder(), List<Holder<Block>> canopy = List.of(Blocks.STONE.builtInRegistryHolder(),
@@ -25,4 +34,12 @@ public final class NativeBlockTags {
BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP, canopy, BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP, canopy,
BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP_NO_LEAVES, solid))).apply(); BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP_NO_LEAVES, solid))).apply();
} }
public static void restoreTags() {
if (originalTags == null) {
return;
}
BuiltInRegistries.BLOCK.prepareTagReload(new TagLoader.LoadResult<>(Registries.BLOCK, originalTags)).apply();
originalTags = null;
}
} }
@@ -34,6 +34,7 @@ import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
import net.minecraft.world.level.levelgen.structure.structures.IglooPieces; import net.minecraft.world.level.levelgen.structure.structures.IglooPieces;
import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece; import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece;
import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure; import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure;
import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
@@ -68,6 +69,11 @@ public class WorldgenTerrainHeightmapsTest {
NativeBlockTags.bindHeightmapFixtures(); NativeBlockTags.bindHeightmapFixtures();
} }
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test @Test
public void authoringHeightmapsReadPlacedObjectsAndFluidsWithoutTerrainResolvers() { public void authoringHeightmapsReadPlacedObjectsAndFluidsWithoutTerrainResolvers() {
ProtoChunk chunk = terrainChunk(new ChunkPos(0, 0)); ProtoChunk chunk = terrainChunk(new ChunkPos(0, 0));
@@ -13,6 +13,7 @@ import com.google.gson.JsonParser;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.junit.AfterClass; import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.io.File; import java.io.File;
@@ -26,6 +27,9 @@ import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
public final class BareMobSpawnHistoryTest { public final class BareMobSpawnHistoryTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final String LEGACY_RENDERER_IDENTITY = "bukkit-v26_3_R1-generated-registry-json-v1"; private static final String LEGACY_RENDERER_IDENTITY = "bukkit-v26_3_R1-generated-registry-json-v1";
private static final String NATURAL_MOB_SPAWNS = "minecraft:gameplay/natural_mob_spawns"; private static final String NATURAL_MOB_SPAWNS = "minecraft:gameplay/natural_mob_spawns";
private static final GenerationRegistryContract.PhysicalResourceKey INSTALLED = private static final GenerationRegistryContract.PhysicalResourceKey INSTALLED =
@@ -29,6 +29,7 @@ import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import net.minecraft.world.level.storage.LevelStorageSource; import net.minecraft.world.level.storage.LevelStorageSource;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.bukkit.craftbukkit.generator.CustomChunkGenerator; import org.bukkit.craftbukkit.generator.CustomChunkGenerator;
@@ -49,6 +50,9 @@ import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
public class HeadlessChunkGeneratorTest { public class HeadlessChunkGeneratorTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -31,6 +31,7 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp
import net.minecraft.world.level.storage.LevelStorageSource; import net.minecraft.world.level.storage.LevelStorageSource;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
@@ -48,6 +49,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class HeadlessChunkLightingTest { public final class HeadlessChunkLightingTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -38,6 +38,7 @@ import net.minecraft.world.ticks.ScheduledTick;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
@@ -54,6 +55,9 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class HeadlessChunkSerializationTest { public class HeadlessChunkSerializationTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -9,6 +9,7 @@ import net.minecraft.world.item.Items;
import java.util.Map; import java.util.Map;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
@@ -18,6 +19,9 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class HeadlessNativeBootstrapTest { public final class HeadlessNativeBootstrapTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Test @Test
public void bindsNativeItemComponentsWithoutStartingServer() throws Exception { public void bindsNativeItemComponentsWithoutStartingServer() throws Exception {
HeadlessNativeBootstrap.bindComponents(HeadlessNativeTestRegistries.get()); HeadlessNativeBootstrap.bindComponents(HeadlessNativeTestRegistries.get());
@@ -7,12 +7,16 @@ import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.Structure;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class HeadlessNativeRegistriesTest { public final class HeadlessNativeRegistriesTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Test @Test
public void nativeStructureBiomeTagsUseTheLoadedBiomeHolders() throws Exception { public void nativeStructureBiomeTagsUseTheLoadedBiomeHolders() throws Exception {
RegistryAccess registries = HeadlessNativeTestRegistries.get(); RegistryAccess registries = HeadlessNativeTestRegistries.get();
@@ -4,6 +4,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.util.Map; import java.util.Map;
@@ -17,6 +18,9 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
public final class HeadlessNativeRegistryTest { public final class HeadlessNativeRegistryTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass @BeforeClass
public static void bootstrap() { public static void bootstrap() {
HeadlessNativeBootstrap.initialize(); HeadlessNativeBootstrap.initialize();
@@ -11,6 +11,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeTileReader;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
import net.minecraft.data.registries.VanillaRegistries; import net.minecraft.data.registries.VanillaRegistries;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
@@ -23,6 +24,9 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
public final class HeadlessNativeTileTest { public final class HeadlessNativeTileTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass @BeforeClass
public static void bootstrap() { public static void bootstrap() {
HeadlessNativeBootstrap.initialize(); HeadlessNativeBootstrap.initialize();
@@ -17,6 +17,7 @@ import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.spigotmc.SpigotWorldConfig; import org.spigotmc.SpigotWorldConfig;
@@ -36,6 +37,9 @@ import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class HeadlessRegionTerrainTest { public final class HeadlessRegionTerrainTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -24,6 +24,7 @@ import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.storage.RegionFile; import net.minecraft.world.level.chunk.storage.RegionFile;
import net.minecraft.world.level.chunk.storage.RegionStorageInfo; import net.minecraft.world.level.chunk.storage.RegionStorageInfo;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.io.DataInputStream; import java.io.DataInputStream;
@@ -38,6 +39,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class NativeRegionTerrainWriterTest { public final class NativeRegionTerrainWriterTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass @BeforeClass
public static void bootstrap() { public static void bootstrap() {
SharedConstants.tryDetectVersion(); SharedConstants.tryDetectVersion();
@@ -26,6 +26,7 @@ import net.minecraft.world.level.material.Fluids;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.junit.AfterClass; import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.util.ArrayList; import java.util.ArrayList;
@@ -40,6 +41,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class OfflineNativeFeatureFeasibilityTest { public final class OfflineNativeFeatureFeasibilityTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static HeadlessNativeRegistries loaded; private static HeadlessNativeRegistries loaded;
private static RegistryAccess registries; private static RegistryAccess registries;
private static DimensionType dimension; private static DimensionType dimension;
@@ -1,6 +1,7 @@
package art.arcane.iris.probe; package art.arcane.iris.probe;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
@@ -22,6 +23,9 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
public final class OfflineRegionPublicationTest { public final class OfflineRegionPublicationTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -0,0 +1,37 @@
package art.arcane.iris.probe;
import io.papermc.paper.registry.PaperRegistryAccess;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
public final class PaperRegistryIsolation implements TestRule {
@Override
public Statement apply(Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
Map<Object, Object> registries = registries();
Map<Object, Object> previous = new HashMap<>(registries);
registries.clear();
try {
base.evaluate();
} finally {
registries.clear();
registries.putAll(previous);
}
}
};
}
@SuppressWarnings("unchecked")
private static Map<Object, Object> registries() throws ReflectiveOperationException {
Field field = PaperRegistryAccess.class.getDeclaredField("registries");
field.setAccessible(true);
return (Map<Object, Object>) field.get(PaperRegistryAccess.instance());
}
}
@@ -18,6 +18,7 @@
package art.arcane.iris; package art.arcane.iris;
import java.util.concurrent.CompletionStage;
import art.arcane.volmlib.util.diagnostics.BukkitDebugDump; import art.arcane.volmlib.util.diagnostics.BukkitDebugDump;
import art.arcane.iris.generation.runtime.IrisEngineEffects; import art.arcane.iris.generation.runtime.IrisEngineEffects;
import art.arcane.iris.generation.runtime.IrisWorldManager; import art.arcane.iris.generation.runtime.IrisWorldManager;
@@ -36,6 +37,7 @@ import art.arcane.iris.world.PendingWorldDeleteQueue;
import art.arcane.iris.world.PendingWorldReplacementManager; import art.arcane.iris.world.PendingWorldReplacementManager;
import art.arcane.iris.world.WorldRefusalReporter; import art.arcane.iris.world.WorldRefusalReporter;
import art.arcane.iris.configuration.SettingsHotloadWatch; import art.arcane.iris.configuration.SettingsHotloadWatch;
import art.arcane.iris.update.IrisUpdateNotifier;
import art.arcane.iris.pack.datapack.ServerConfigurator; import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.pack.datapack.DatapackIngestService; import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.pack.datapack.DatapackIngestService.StartupValidationOutcome; import art.arcane.iris.pack.datapack.DatapackIngestService.StartupValidationOutcome;
@@ -131,8 +133,11 @@ import org.bukkit.command.Command;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.Event; import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.HandlerList; import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.server.PluginDisableEvent;
import org.bukkit.event.server.PluginEnableEvent;
import org.bukkit.generator.BiomeProvider; import org.bukkit.generator.BiomeProvider;
import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.ChunkGenerator;
import org.bukkit.plugin.IllegalPluginAccessException; import org.bukkit.plugin.IllegalPluginAccessException;
@@ -204,6 +209,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private volatile PlaceholderRegistration papiRegistration; private volatile PlaceholderRegistration papiRegistration;
private volatile IrisPapiListener papiListener; private volatile IrisPapiListener papiListener;
private volatile IrisPapiState papiState; private volatile IrisPapiState papiState;
private volatile boolean papiLifecycleActive;
private KMap<Class<? extends IrisService>, IrisService> services; private KMap<Class<? extends IrisService>, IrisService> services;
// Copy-on-write: mutated on the main thread during enable() and iterated by the JVM // Copy-on-write: mutated on the main thread during enable() and iterated by the JVM
// shutdown-hook thread during teardown; a plain list would CME and abort the teardown. // shutdown-hook thread during teardown; a plain list would CME and abort the teardown.
@@ -214,6 +220,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this); private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this);
private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this); private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this);
private BukkitLanguageSwitcher languageSwitcher; private BukkitLanguageSwitcher languageSwitcher;
private IrisUpdateNotifier updateNotifier;
private BukkitDebugDump debugDump; private BukkitDebugDump debugDump;
private volatile SettingsHotloadWatch settingsHotloadWatch; private volatile SettingsHotloadWatch settingsHotloadWatch;
private volatile Thread serverLifecycleThread; private volatile Thread serverLifecycleThread;
@@ -636,6 +643,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
languageSwitcher = BukkitLanguageSwitcher.register(this, IrisLanguage.selections(), languageSwitcher = BukkitLanguageSwitcher.register(this, IrisLanguage.selections(),
new BukkitLanguageSwitcher.Options("iris", "iris.all", new BukkitLanguageSwitcher.Options("iris", "iris.all",
DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver(), IrisLanguage.editorOptions())); DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver(), IrisLanguage.editorOptions()));
updateNotifier = new IrisUpdateNotifier(this);
PaperLibBootstrap.install(); PaperLibBootstrap.install();
SimdSupport.install(); SimdSupport.install();
timings.mark("bootstrap"); timings.mark("bootstrap");
@@ -863,7 +871,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
*/ */
private CompletableFuture<ManagedWorldLoader.ManagedWorldLoad> loadManagedWorld(String configuredWorldName) { private CompletableFuture<ManagedWorldLoader.ManagedWorldLoad> loadManagedWorld(String configuredWorldName) {
return worldReconciler.loadWorld(ServerProperties.BUKKIT_YML, configuredWorldName) return worldReconciler.loadWorld(ServerProperties.BUKKIT_YML, configuredWorldName)
.thenApply(result -> new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message())); .thenApply(this::managedWorldLoadResult);
}
private ManagedWorldLoader.ManagedWorldLoad managedWorldLoadResult(BukkitWorldReconciler.LoadResult result) {
if (!result.succeeded() && result.failure() != null) {
reportError("Could not explicitly load Iris world " + result.worldKey() + ": " + result.message(), result.failure());
}
return new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message());
} }
public PendingWorldReplacementManager pendingWorldReplacements() { public PendingWorldReplacementManager pendingWorldReplacements() {
@@ -927,6 +942,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
ServerConfigurator.restartAtStartupBoundary(restartReason); ServerConfigurator.restartAtStartupBoundary(restartReason);
return; return;
} }
papiLifecycleActive = true;
setupPapi();
reportLockedRuntime(); reportLockedRuntime();
} }
@@ -973,6 +990,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
public void onDisable() { public void onDisable() {
preservePendingBlockDrops();
papiLifecycleActive = false;
if (updateNotifier != null) {
updateNotifier.close();
updateNotifier = null;
}
if (debugDump != null) { if (debugDump != null) {
debugDump.close(); debugDump.close();
debugDump = null; debugDump = null;
@@ -1011,20 +1034,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
@Override @Override
public void onPreUnload(ReloadAware.PreUnloadReason reason) { public CompletionStage<Void> commitReload(ReloadAware.PreUnloadReason reason) {
preservePendingBlockDrops();
papiLifecycleActive = false;
teardownPapi(); teardownPapi();
if (IrisToolbelt.isServerStopping()) { if (IrisToolbelt.isServerStopping()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason); quiesceRuntimeForServerShutdown("pre-unload:" + reason);
startPostStopFinisher(); startPostStopFinisher();
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers."); Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
return; return CompletableFuture.completedFuture(null);
} }
if (alreadyDrained.get()) { if (alreadyDrained.get()) {
Iris.debug("Pre-unload hook skipped; Iris already drained."); Iris.debug("Pre-unload hook skipped; Iris already drained.");
return; return CompletableFuture.completedFuture(null);
} }
Iris.debug("BileTools pre-unload hook fired (" + reason + "). Freezing all Iris worlds."); Iris.debug("BileTools pre-unload hook fired (" + reason + "). Freezing all Iris worlds.");
drainOnce("pre-unload:" + reason, 45L); drainOnce("pre-unload:" + reason, 45L);
return CompletableFuture.completedFuture(null);
} }
/** /**
@@ -1259,6 +1285,20 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
&& preservation.hasActiveResources(); && preservation.hasActiveResources();
} }
private void preservePendingBlockDrops() {
for (World world : Bukkit.getWorlds()) {
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator != null && generator.getEngine() != null
&& generator.getEngine().getWorldManager() instanceof IrisWorldManager manager) {
manager.flushPendingBlockDropsAsync().whenComplete((ignored, failure) -> {
if (failure != null) {
Iris.reportError("Failed to preserve pending block loot before Iris teardown.", failure);
}
});
}
}
}
private void drainWorldGenerators(String reason, long timeoutSeconds) { private void drainWorldGenerators(String reason, long timeoutSeconds) {
List<World> irisWorlds = new ArrayList<>(); List<World> irisWorlds = new ArrayList<>();
List<PlatformChunkGenerator> generators = new ArrayList<>(); List<PlatformChunkGenerator> generators = new ArrayList<>();
@@ -1313,8 +1353,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
} }
private void setupPapi() { @EventHandler
if (!PlaceholderRegistration.isPlaceholderApiEnabled()) { public void onPlaceholderApiEnable(PluginEnableEvent event) {
if ("PlaceholderAPI".equals(event.getPlugin().getName())) {
setupPapi();
}
}
@EventHandler
public void onPlaceholderApiDisable(PluginDisableEvent event) {
if ("PlaceholderAPI".equals(event.getPlugin().getName())) {
teardownPapi();
}
}
private synchronized void setupPapi() {
if (!papiLifecycleActive || papiRegistration != null
|| !PlaceholderRegistration.isPlaceholderApiEnabled()) {
return; return;
} }
@@ -1341,7 +1396,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
papiRegistration = registration; papiRegistration = registration;
} }
private void teardownPapi() { private synchronized void teardownPapi() {
IrisPapiListener listener = papiListener; IrisPapiListener listener = papiListener;
papiListener = null; papiListener = null;
@@ -25,6 +25,8 @@ import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.pack.datapack.DatapackIngestService; import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.studio.object.ObjectStudioSaveService; import art.arcane.iris.studio.object.ObjectStudioSaveService;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.locator.SubterrainLocator;
import art.arcane.iris.generation.subterrain.IrisSubterrainFamily;
import art.arcane.iris.structure.placement.IrisStructureLocator; import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy; import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability; import art.arcane.iris.structure.placement.StructureReachability;
@@ -72,7 +74,7 @@ import java.util.Set;
@Director(name = "find", origin = DirectorOrigin.PLAYER, description = "Iris Find commands", descriptionKey = "iris.director.commandfind.director.iris_find_commands", aliases = "goto") @Director(name = "find", origin = DirectorOrigin.PLAYER, description = "Iris Find commands", descriptionKey = "iris.director.commandfind.director.iris_find_commands", aliases = "goto")
public class CommandFind implements DirectorExecutor { public class CommandFind implements DirectorExecutor {
@Director(description = "Find a biome", descriptionKey = "iris.director.commandfind.director.find_biome") @Director(description = "Find a biome", sync = true, descriptionKey = "iris.director.commandfind.director.find_biome")
public void biome( public void biome(
@Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class) @Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class)
IrisBiome biome, IrisBiome biome,
@@ -89,6 +91,78 @@ public class CommandFind implements DirectorExecutor {
EngineBukkitOps.gotoBiome(e, biome, player(), teleport); EngineBukkitOps.gotoBiome(e, biome, player(), teleport);
} }
@Director(description = "Find an authored underground feature by id or geometry family", sync = true)
public void subterrain(
@Param(description = "Feature definition id or family: tectonic_fault, cenote, lava_tube, travertine_terraces")
String feature,
@Param(description = "Search radius in blocks", defaultValue = "8192")
int radius,
@Param(description = "Should you be teleported", defaultValue = "true")
boolean teleport
) {
String key = feature.trim();
IrisSubterrainFamily family = null;
try {
family = IrisSubterrainFamily.valueOf(key.toUpperCase(Locale.ROOT).replace('-', '_'));
} catch (IllegalArgumentException ignored) {
}
locateSubterrain(new SubterrainLocator.Query(family == null ? key : "", family, ""), radius, teleport);
}
@Director(name = "underground-biome", description = "Find a bounded authored underground biome", sync = true)
public void undergroundBiome(
@Param(description = "The underground biome to look for", customHandler = ReachableBiomeHandler.class)
IrisBiome biome,
@Param(description = "Search radius in blocks", defaultValue = "8192")
int radius,
@Param(description = "Should you be teleported", defaultValue = "true")
boolean teleport
) {
locateSubterrain(new SubterrainLocator.Query("", null, biome.getLoadKey()), radius, teleport);
}
private void locateSubterrain(SubterrainLocator.Query query, int radius, boolean teleport) {
Engine activeEngine = engine();
VolmitSender commandSender = sender();
Player target = player();
if (activeEngine == null || commandSender == null || target == null) {
if (commandSender != null) {
commandSender.sendMessage(C.RED + "Run this command from an Iris world.");
}
return;
}
if (radius < 0 || radius > 32768) {
commandSender.sendMessage(C.RED + "Search radius must be between 0 and 32768 blocks.");
return;
}
Location origin = target.getLocation();
World world = origin.getWorld();
commandSender.sendMessage(C.GRAY + "Searching authored underground plans...");
J.a(() -> {
try {
SubterrainLocator.Result found = GenerationSemanticQueries.nearestSubterrain(activeEngine, query,
origin.getBlockX(), origin.getBlockY(), origin.getBlockZ(), radius).orElse(null);
if (found == null) {
sendStructureMessage(target, commandSender,
C.YELLOW + "No matching authored underground volume found within " + radius + " blocks.");
return;
}
String label = found.family().name().toLowerCase(Locale.ROOT) + " " + found.featureId();
if (!teleport) {
sendStructureMessage(target, commandSender, C.GREEN + "Found " + label + " at "
+ found.x() + ", " + found.y() + ", " + found.z() + ".");
return;
}
prepareStructureTeleport(target, world, commandSender, label,
new Location(world, found.x(), found.y(), found.z()), true);
} catch (Throwable error) {
sendStructureMessage(target, commandSender,
C.RED + "Could not locate authored underground volume: " + error.getMessage());
Iris.reportError("Could not locate authored underground volume.", error);
}
});
}
@Director(description = "Find a region", descriptionKey = "iris.director.commandfind.director.find_region") @Director(description = "Find a region", descriptionKey = "iris.director.commandfind.director.find_region")
public void region( public void region(
@Param(description = "The region to look for", descriptionKey = "iris.director.commandfind.param.region_look", customHandler = ReachableRegionHandler.class) @Param(description = "The region to look for", descriptionKey = "iris.director.commandfind.param.region_look", customHandler = ReachableRegionHandler.class)
@@ -18,6 +18,8 @@
package art.arcane.iris.command; package art.arcane.iris.command;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.pack.validation.PackCompatReport; import art.arcane.iris.pack.validation.PackCompatReport;
import art.arcane.iris.pack.PackDirectoryResolver; import art.arcane.iris.pack.PackDirectoryResolver;
@@ -174,7 +176,6 @@ public class CommandPack implements DirectorExecutor {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack)))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return; return;
} }
PackValidationRegistry.remove(packFolder.getName());
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_QUARANTINED_CLEANUP_CANDIDATE_S_UNDER, MessageArgument.untrusted("size", String.valueOf(result.quarantinedPaths().size())), MessageArgument.untrusted("quarantinePath", String.valueOf(result.quarantinePath())))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_QUARANTINED_CLEANUP_CANDIDATE_S_UNDER, MessageArgument.untrusted("size", String.valueOf(result.quarantinedPaths().size())), MessageArgument.untrusted("quarantinePath", String.valueOf(result.quarantinePath()))));
reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED); reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED);
return; return;
@@ -230,7 +231,6 @@ public class CommandPack implements DirectorExecutor {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack)))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return; return;
} }
PackValidationRegistry.remove(packFolder.getName());
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_RESTORED_FILE_S_FROM, MessageArgument.untrusted("size", String.valueOf(result.restoredPaths().size())), MessageArgument.untrusted("dumpPath", String.valueOf(result.dumpPath())))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_RESTORED_FILE_S_FROM, MessageArgument.untrusted("size", String.valueOf(result.restoredPaths().size())), MessageArgument.untrusted("dumpPath", String.valueOf(result.dumpPath()))));
reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED); reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED);
return; return;
@@ -363,47 +363,79 @@ public class CommandPack implements DirectorExecutor {
return persisted == null || persisted.isBlank() ? "unknown" : persisted; return persisted == null || persisted.isBlank() ? "unknown" : persisted;
} }
private PackValidationResult runValidate(VolmitSender s, File packFolder) { PackValidationResult runValidate(VolmitSender s, File packFolder) {
PackValidationRegistry.ValidationTicket ticket =
PackValidationRegistry.tryBeginValidation(packFolder.toPath());
PackValidationResult result;
String contentFingerprint = "";
String contextFingerprint = "";
try { try {
PackValidationResult result = PackValidator.validate(packFolder); if (ticket == null) {
PackValidationRegistry.publish(result); throw new IllegalStateException("Pack content is currently being changed.");
reportResult(s, result); }
return result; contentFingerprint = PackFingerprints.computePackTreeFingerprint(packFolder);
contextFingerprint = PackValidationCache.contextFingerprint();
result = PackValidator.validate(packFolder);
if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packFolder))
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
throw new IllegalStateException("Pack content or validation context changed during validation.");
}
} catch (Throwable e) { } catch (Throwable e) {
Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e); Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e);
String detail = e.getMessage() == null || e.getMessage().isBlank() String detail = e.getMessage() == null || e.getMessage().isBlank()
? e.getClass().getSimpleName() ? e.getClass().getSimpleName()
: e.getMessage(); : e.getMessage();
PackValidationResult result = new PackValidationResult( result = new PackValidationResult(
packFolder.getName(), packFolder.getName(),
List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail), List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail),
List.of(), List.of(),
System.currentTimeMillis()); System.currentTimeMillis());
contentFingerprint = "";
contextFingerprint = "";
}
if (PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) {
PackValidationRegistry.publish(result); PackValidationRegistry.publish(result);
} else {
result = new PackValidationResult(packFolder.getName(),
List.of("Pack content changed during validation."), List.of(), System.currentTimeMillis());
}
reportResult(s, result); reportResult(s, result);
return result; return result;
} }
}
private void persistValidationCache(File packsRoot) { private void persistValidationCache(File packsRoot) {
try {
String contextFingerprint = PackValidationCache.contextFingerprint();
PackFingerprints.PackContentSnapshot snapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<PackValidationResult> results = new ArrayList<>(packDirectories.size()); List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
for (File packDirectory : packDirectories) { for (File packDirectory : packDirectories) {
PackValidationResult result = PackValidationRegistry.get(packDirectory.getName()); PackValidationResult result = PackValidationRegistry.getMatching(
packDirectory.toPath(), snapshot.packContents().get(packDirectory.getName()));
if (result == null) { if (result == null) {
// The boot cache only loads when it covers every pack, so a partial write is
// pointless - but say so instead of silently skipping the persist forever.
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName() Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
+ "\" has no registered result. Run /iris pack validate all to repopulate it."); + "\" has no validation matching its current content. Run /iris pack validate to refresh it.");
return; return;
} }
results.add(result); results.add(result);
} }
try { if (!snapshot.content().equals(PackFingerprints.computePackContentSnapshot(packsRoot).content())
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
Iris.warn("Pack validation cache not written because pack content or validation context changed.");
return;
}
for (int index = 0; index < packDirectories.size(); index++) {
File packDirectory = packDirectories.get(index);
if (PackValidationRegistry.getMatching(packDirectory.toPath(),
snapshot.packContents().get(packDirectory.getName())) != results.get(index)) {
Iris.warn("Pack validation cache not written because validation changed during persistence.");
return;
}
}
PackValidationCache.save( PackValidationCache.save(
Iris.instance.getDataFile("cache", "pack-validation.json").toPath(), Iris.instance.getDataFile("cache", "pack-validation.json").toPath(),
PackValidationCache.contentFingerprint(packsRoot), snapshot.content(),
PackValidationCache.contextFingerprint(), contextFingerprint,
results); results);
} catch (IOException | RuntimeException e) { } catch (IOException | RuntimeException e) {
Iris.reportError("Could not persist refreshed pack-validation results", e); Iris.reportError("Could not persist refreshed pack-validation results", e);
@@ -6,6 +6,7 @@ import art.arcane.iris.generation.runtime.IrisTelemetrySnapshot;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.generation.runtime.EngineTelemetrySnapshot; import art.arcane.iris.generation.runtime.EngineTelemetrySnapshot;
import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.world.task.J;
import art.arcane.iris.platform.bukkit.plugin.IrisService; import art.arcane.iris.platform.bukkit.plugin.IrisService;
import art.arcane.volmlib.integration.IntegrationHandshakeRequest; import art.arcane.volmlib.integration.IntegrationHandshakeRequest;
import art.arcane.volmlib.integration.IntegrationHandshakeResponse; import art.arcane.volmlib.integration.IntegrationHandshakeResponse;
@@ -13,6 +14,9 @@ import art.arcane.volmlib.integration.IntegrationHeartbeat;
import art.arcane.volmlib.integration.IntegrationMetricDescriptor; import art.arcane.volmlib.integration.IntegrationMetricDescriptor;
import art.arcane.volmlib.integration.IntegrationMetricGroup; import art.arcane.volmlib.integration.IntegrationMetricGroup;
import art.arcane.volmlib.integration.IntegrationMetricSample; import art.arcane.volmlib.integration.IntegrationMetricSample;
import art.arcane.volmlib.integration.IntegrationMetricPublisher;
import art.arcane.volmlib.integration.IntegrationMetricSnapshot;
import art.arcane.volmlib.integration.IntegrationSnapshotProvider;
import art.arcane.volmlib.integration.IntegrationMetricSchema; import art.arcane.volmlib.integration.IntegrationMetricSchema;
import art.arcane.volmlib.integration.IntegrationProtocolNegotiator; import art.arcane.volmlib.integration.IntegrationProtocolNegotiator;
import art.arcane.volmlib.integration.IntegrationProtocolVersion; import art.arcane.volmlib.integration.IntegrationProtocolVersion;
@@ -30,7 +34,7 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.function.Supplier; import java.util.function.Supplier;
public class IrisIntegrationService implements IrisService, IntegrationServiceContract { public class IrisIntegrationService implements IrisService, IntegrationSnapshotProvider {
private static final IntegrationProtocolVersion CURRENT_PROTOCOL = new IntegrationProtocolVersion(1, 2); private static final IntegrationProtocolVersion CURRENT_PROTOCOL = new IntegrationProtocolVersion(1, 2);
private static final Set<IntegrationProtocolVersion> SUPPORTED_PROTOCOLS = Set.of( private static final Set<IntegrationProtocolVersion> SUPPORTED_PROTOCOLS = Set.of(
new IntegrationProtocolVersion(1, 0), new IntegrationProtocolVersion(1, 0),
@@ -41,6 +45,7 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
"handshake", "handshake",
"heartbeat", "heartbeat",
"metrics", "metrics",
IntegrationSnapshotProvider.CAPABILITY,
"metric-groups", "metric-groups",
"iris-engine-metrics", "iris-engine-metrics",
"iris-world-metrics" "iris-world-metrics"
@@ -63,6 +68,9 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
); );
private final Supplier<IrisTelemetrySnapshot> telemetrySupplier; private final Supplier<IrisTelemetrySnapshot> telemetrySupplier;
private final IntegrationMetricPublisher snapshots = new IntegrationMetricPublisher(
IntegrationMetricSchema.irisKeys().size(), 30_000L);
private int snapshotTaskId = -1;
public IrisIntegrationService() { public IrisIntegrationService() {
this(IrisIntegrationService::currentTelemetry); this(IrisIntegrationService::currentTelemetry);
@@ -77,11 +85,17 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
@Override @Override
public void onEnable() { public void onEnable() {
Bukkit.getServicesManager().register(IntegrationServiceContract.class, this, Iris.instance, ServicePriority.Normal); Bukkit.getServicesManager().register(IntegrationServiceContract.class, this, Iris.instance, ServicePriority.Normal);
snapshotTaskId = J.ar(this::publishSnapshots, 20);
Iris.verbose("Integration provider registered for Iris"); Iris.verbose("Integration provider registered for Iris");
} }
@Override @Override
public void onDisable() { public void onDisable() {
if (snapshotTaskId != -1) {
J.car(snapshotTaskId);
snapshotTaskId = -1;
}
snapshots.clear();
Bukkit.getServicesManager().unregister(IntegrationServiceContract.class, this); Bukkit.getServicesManager().unregister(IntegrationServiceContract.class, this);
} }
@@ -188,6 +202,20 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
return Map.copyOf(selected); return Map.copyOf(selected);
} }
@Override
public IntegrationMetricSnapshot snapshotMetrics(Set<String> metricKeys) {
return snapshots.snapshotMetrics(metricKeys, System.currentTimeMillis());
}
void publishSnapshots() {
IntegrationMetricPublisher.Demand demand = snapshots.demandedKeys(System.currentTimeMillis());
if (demand.keys().isEmpty()) {
return;
}
Map<String, IntegrationMetricSample> samples = sampleMetrics(demand.keys());
snapshots.publish(demand, System.currentTimeMillis(), samples);
}
@Override @Override
public List<IntegrationMetricGroup> metricGroups() { public List<IntegrationMetricGroup> metricGroups() {
IrisTelemetrySnapshot snapshot = safeSnapshot(); IrisTelemetrySnapshot snapshot = safeSnapshot();
@@ -0,0 +1,37 @@
package art.arcane.iris.update;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.volmlib.util.update.BukkitUpdateService;
import org.bukkit.plugin.Plugin;
import java.util.function.Consumer;
public final class IrisUpdateNotifier implements AutoCloseable {
private final Consumer<IrisSettings> settingsListener = this::reconfigure;
private final BukkitUpdateService updates;
private volatile boolean enabled;
private boolean closed;
public IrisUpdateNotifier(Plugin plugin) {
enabled = IrisSettings.get().getGeneral().isUpdateNotifications();
updates = BukkitUpdateService.register(plugin, new BukkitUpdateService.Options(
"VolmitSoftware", "Iris", "iris.update", () -> enabled));
IrisSettings.addSettingsListener(settingsListener);
reconfigure(IrisSettings.get());
}
private synchronized void reconfigure(IrisSettings settings) {
if (closed) {
return;
}
enabled = settings.getGeneral().isUpdateNotifications();
updates.reconfigure();
}
@Override
public synchronized void close() {
closed = true;
IrisSettings.removeSettingsListener(settingsListener);
updates.close();
}
}
@@ -18,8 +18,9 @@
package art.arcane.iris.world; package art.arcane.iris.world;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.world.lifecycle.WorldLifecycleStaging; import art.arcane.iris.world.lifecycle.WorldLifecycleStaging;
@@ -184,12 +185,12 @@ public final class IrisWorldGeneratorResolver {
} }
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList(); List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath(); Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
ServerConfigurator.PackContentSnapshot contentSnapshot = PackFingerprints.PackContentSnapshot contentSnapshot =
new ServerConfigurator.PackContentSnapshot("", Map.of()); new PackFingerprints.PackContentSnapshot("", Map.of());
String contextFingerprint = ""; String contextFingerprint = "";
Optional<List<PackValidationResult>> cached = Optional.empty(); Optional<List<PackValidationResult>> cached = Optional.empty();
try { try {
contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot); contentSnapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
contextFingerprint = PackValidationCache.contextFingerprint(); contextFingerprint = PackValidationCache.contextFingerprint();
if (!externalContentChanged) { if (!externalContentChanged) {
cached = PackValidationCache.load( cached = PackValidationCache.load(
@@ -274,19 +275,19 @@ public final class IrisWorldGeneratorResolver {
private static FreshValidation validateStablePacks( private static FreshValidation validateStablePacks(
File packsRoot, File packsRoot,
List<File> initialPackDirs, List<File> initialPackDirs,
ServerConfigurator.PackContentSnapshot initialSnapshot PackFingerprints.PackContentSnapshot initialSnapshot
) { ) {
List<File> packDirs = initialPackDirs; List<File> packDirs = initialPackDirs;
ServerConfigurator.PackContentSnapshot before = initialSnapshot; PackFingerprints.PackContentSnapshot before = initialSnapshot;
for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) { for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) {
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList(); List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
List<PackValidationResult> results = validatePacks(packDirs); List<PackValidationResult> results = validatePacks(packDirs);
ServerConfigurator.PackContentSnapshot after; PackFingerprints.PackContentSnapshot after;
try { try {
after = ServerConfigurator.computePackContentSnapshot(packsRoot); after = PackFingerprints.computePackContentSnapshot(packsRoot);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
Iris.reportError("Could not verify Iris pack bytes after validation", exception); Iris.reportError("Could not verify Iris pack bytes after validation", exception);
after = new ServerConfigurator.PackContentSnapshot("", Map.of()); after = new PackFingerprints.PackContentSnapshot("", Map.of());
} }
List<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); List<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList(); List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList();
@@ -923,7 +924,7 @@ public final class IrisWorldGeneratorResolver {
private record FreshValidation( private record FreshValidation(
List<File> packDirs, List<File> packDirs,
ServerConfigurator.PackContentSnapshot contentSnapshot, PackFingerprints.PackContentSnapshot contentSnapshot,
List<PackValidationResult> results, List<PackValidationResult> results,
boolean stable boolean stable
) { ) {
@@ -3,19 +3,33 @@ package art.arcane.iris.world.tree;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.inventory.ItemStack;
import java.util.Collection;
import java.util.Objects;
final class RoutedBlockBreakEvent extends BlockBreakEvent implements BlockDropRouter { final class RoutedBlockBreakEvent extends BlockBreakEvent implements BlockDropRouter {
private final TreeFellerSVC service; private final TreeFellerSVC service;
private final FellingRun run; private final FellingRun run;
private final ItemStack tool;
RoutedBlockBreakEvent(Block block, Player player, FellingRun run, TreeFellerSVC service) { RoutedBlockBreakEvent(Options options) {
super(block, player); super(options.block(), options.player());
this.run = run; run = options.run();
this.service = service; service = options.service();
tool = Objects.requireNonNull(options.tool(), "tool").clone();
}
@Override
public Collection<ItemStack> captureNativeDrops() {
return getBlock().getDrops(tool);
} }
@Override @Override
public boolean routeDrop(Object drop) { public boolean routeDrop(Object drop) {
return service.isServiceEnabled() && run.presentation.routeDrop(drop); return service.isServiceEnabled() && run.presentation.routeDrop(drop);
} }
record Options(Block block, Player player, FellingRun run, TreeFellerSVC service, ItemStack tool) {
}
} }
@@ -387,7 +387,8 @@ final class TreeFellingRunner {
return; return;
} }
BlockBreakEvent probe = new RoutedBlockBreakEvent(block, run.candidate.player(), run, service); BlockBreakEvent probe = new RoutedBlockBreakEvent(new RoutedBlockBreakEvent.Options(
block, run.candidate.player(), run, service, reservation.toolForDrops()));
service.managedEvents.add(probe); service.managedEvents.add(probe);
try { try {
Bukkit.getPluginManager().callEvent(probe); Bukkit.getPluginManager().callEvent(probe);
@@ -10,6 +10,9 @@ authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
website: VolmitSoftware.com website: VolmitSoftware.com
description: More than a Dimension! description: More than a Dimension!
permissions: permissions:
iris.update:
description: Allows viewing Iris updates in VolmLib's combined update reports.
default: op
iris.debugdump: iris.debugdump:
description: Allows creating and optionally uploading diagnostic reports. description: Allows creating and optionally uploading diagnostic reports.
default: op default: op
@@ -25,34 +28,26 @@ permissions:
dependencies: dependencies:
server: server:
PlaceholderAPI: PlaceholderAPI:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
CraftEngine: CraftEngine:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
Nexo: Nexo:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
Oraxen: Oraxen:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
ItemsAdder: ItemsAdder:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
SCore: SCore:
load: BEFORE
required: false
join-classpath: true
ExecutableItems:
load: BEFORE
required: false
join-classpath: false
MythicLib:
load: OMIT load: OMIT
required: false required: false
join-classpath: true join-classpath: true
@@ -61,11 +56,7 @@ dependencies:
required: false required: false
join-classpath: true join-classpath: true
eco: eco:
load: BEFORE load: OMIT
required: false
join-classpath: true
EcoItems:
load: BEFORE
required: false required: false
join-classpath: true join-classpath: true
MythicMobs: MythicMobs:
@@ -77,7 +68,7 @@ dependencies:
required: false required: false
join-classpath: true join-classpath: true
KGenerators: KGenerators:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
Multiverse-Core: Multiverse-Core:
@@ -85,6 +76,6 @@ dependencies:
required: false required: false
join-classpath: true join-classpath: true
WorldEdit: WorldEdit:
load: BEFORE load: OMIT
required: false required: false
join-classpath: true join-classpath: true
@@ -3,18 +3,6 @@ version: ${version}
main: ${main} main: ${main}
folia-supported: true folia-supported: true
load: STARTUP load: STARTUP
softdepend:
- PlaceholderAPI
- CraftEngine
- Nexo
- Oraxen
- ItemsAdder
- SCore
- ExecutableItems
- eco
- EcoItems
- KGenerators
- WorldEdit
loadbefore: [ Multiverse-Core ] loadbefore: [ Multiverse-Core ]
authors: [ cyberpwn, NextdoorPsycho, Vatuu ] authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
website: VolmitSoftware.com website: VolmitSoftware.com
@@ -23,6 +11,9 @@ commands:
iris: iris:
aliases: [ ir, irs ] aliases: [ ir, irs ]
permissions: permissions:
iris.update:
description: Allows viewing Iris updates in VolmLib's combined update reports.
default: op
iris.debugdump: iris.debugdump:
description: Allows creating and optionally uploading diagnostic reports. description: Allows creating and optionally uploading diagnostic reports.
default: op default: op
@@ -0,0 +1,47 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 96,
"max": 96
}
],
"decorators": [
{
"palette": [
{
"block": "minecraft:oak_stairs",
"data": {
"half": "bottom",
"facing": "north"
}
}
],
"chance": 1,
"forcePlace": true
}
]
}
@@ -0,0 +1,28 @@
{
"name": "Decorator Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
}
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,40 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-decorators',
description: 'Verify authored stair decorations occupy one block and retain their half state.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Decorator generation requires an isolated instance')
await context.step('create a world with authored stair decorations', async () => {
const response = await context.command('/iris create name=decorator_probe type=decorator-fixture seed=78264193',
/Successfully created your world|Failed to create/, 180000)
context.expect(response.includes('Successfully created your world'), response)
})
await context.step('client sees single bottom-half stair decorations', async () => {
let checked = 0
await context.waitUntil(() => {
checked = 0
const center = context.bot.entity.position.floored()
for (let x = -6; x <= 6; x++) {
for (let z = -6; z <= 6; z++) {
for (let y = -4; y <= 1; y++) {
const position = center.offset(x, y, z)
if (context.bot.blockAt(position)?.name !== 'lime_wool') continue
const decoration = context.bot.blockAt(position.offset(0, 1, 0))
const above = context.bot.blockAt(position.offset(0, 2, 0))
if (!decoration || !above) return false
if (decoration.name !== 'oak_stairs' || decoration.getProperties().half !== 'bottom'
|| above.name !== 'air') return false
checked++
}
}
}
return checked >= 100
}, { label: 'At least 100 authored stair columns with empty space above', timeoutMs: 60000, intervalMs: 250 })
context.expect(checked >= 100, 'Client verified generated decorator columns', { checked })
context.report.decoratorColumns = checked
})
}
}
@@ -4,6 +4,13 @@ import art.arcane.volmlib.nativelib.NativeAdapters;
import art.arcane.iris.world.runtime.RuntimeInjection; import art.arcane.iris.world.runtime.RuntimeInjection;
import art.arcane.iris.world.IrisCreator; import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.IrisToolbelt; import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.IrisWorldStorage;
import art.arcane.iris.world.WorldCreatorCompat;
import art.arcane.iris.world.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.platform.bootstrap.ServerProperties;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.block.Biome; import org.bukkit.block.Biome;
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess; import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
@@ -19,6 +26,10 @@ import org.bukkit.command.CommandSender;
import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.ChunkGenerator;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.List; import java.util.List;
import java.util.logging.Level; import java.util.logging.Level;
@@ -26,6 +37,7 @@ import java.util.logging.Level;
public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution { public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution {
private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe"); private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe");
private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe"); private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe");
private static final NamespacedKey COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe");
private static final long WORLD_SEED = 78264193L; private static final long WORLD_SEED = 78264193L;
private NativeWorldRuntime runtime; private NativeWorldRuntime runtime;
@@ -45,7 +57,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
@Override @Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length != 1) { if (arguments.length != 1) {
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload"); sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry");
return true; return true;
} }
try { try {
@@ -56,6 +68,8 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
case "terrain-create" -> createTerrain(sender); case "terrain-create" -> createTerrain(sender);
case "terrain-check" -> checkTerrain(sender); case "terrain-check" -> checkTerrain(sender);
case "terrain-unload" -> unloadTerrain(sender); case "terrain-unload" -> unloadTerrain(sender);
case "lifecycle-status" -> lifecycleStatus(sender);
case "lifecycle-entry" -> lifecycleEntry(sender);
case "status" -> sender.sendMessage("NATIVE_STATUS available=" + runtime.available() case "status" -> sender.sendMessage("NATIVE_STATUS available=" + runtime.available()
+ " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null)); + " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null));
default -> throw new IllegalArgumentException("Unknown lifecycle operation"); default -> throw new IllegalArgumentException("Unknown lifecycle operation");
@@ -66,6 +80,61 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
return true; return true;
} }
private void lifecycleStatus(CommandSender sender) throws Exception {
World world = Bukkit.getWorld(COMMAND_KEY);
Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath();
Path levelRoot = IrisWorldStorage.levelRoot().toPath();
Path registry = levelRoot.resolve("iris/worlds.json");
boolean registered = Files.exists(registry)
&& Files.readString(registry).contains("\"" + COMMAND_KEY + "\"");
String startupName = IrisWorldStorage.configuredWorldName(COMMAND_KEY, levelRoot.getFileName().toString());
YamlConfiguration configuration = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
boolean configured = configuration.getConfigurationSection("worlds") != null
&& configuration.getConfigurationSection("worlds").contains(startupName);
File quarantineRoot = levelRoot.resolve("dimensions/iris").toFile();
File[] quarantines = quarantineRoot.listFiles((directory, name) -> name.startsWith(".iris-delete-"));
int quarantineCount = quarantines == null ? 0 : quarantines.length;
Path queueFile = Bukkit.getPluginManager().getPlugin("Iris").getDataFolder().toPath()
.resolve("pending-world-deletes.txt");
List<String> queue = Files.exists(queueFile) ? Files.readAllLines(queueFile) : List.of();
boolean queued = quarantineCount > 0;
if (quarantines != null) {
for (File quarantine : quarantines) {
queued &= queue.contains(quarantine.getName());
}
}
boolean disk = Files.exists(storage, LinkOption.NOFOLLOW_LINKS);
String cleanup = disk ? "present" : quarantineCount == 0 ? "deleted" : queued ? "queued" : "unsafe";
boolean idle = true;
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
for (LifecycleOperationCoordinator.Domain domain : LifecycleOperationCoordinator.Domain.values()) {
idle &= coordinator.active(domain).isEmpty();
}
sender.sendMessage("IRIS_LIFECYCLE loaded=" + (world != null) + " registered=" + registered
+ " configured=" + configured + " disk=" + disk + " quarantines=" + quarantineCount
+ " cleanup=" + cleanup + " idle=" + idle);
}
private void lifecycleEntry(CommandSender sender) {
if (!(sender instanceof Player player)) {
throw new IllegalArgumentException("Lifecycle entry requires a player");
}
World world = Bukkit.getWorld(COMMAND_KEY);
if (world == null || player.getWorld() != world || world.getSeed() != WORLD_SEED
|| !IrisToolbelt.isIrisWorld(world)) {
throw new IllegalStateException("Player did not enter the requested seeded Iris world");
}
Location feet = player.getLocation();
Material floor = world.getBlockAt(feet.getBlockX(), feet.getBlockY() - 1, feet.getBlockZ()).getType();
if (floor != Material.LIME_WOOL || !floor.isSolid()
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY(), feet.getBlockZ()).isPassable()
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY() + 1, feet.getBlockZ()).isPassable()) {
throw new IllegalStateException("Lifecycle player entry lacks a safe fixture floor");
}
sender.sendMessage("IRIS_LIFECYCLE_ENTRY world=" + COMMAND_KEY + " seed=" + world.getSeed()
+ " floor=lime_wool safe=true");
}
private void create(CommandSender sender) throws ReflectiveOperationException { private void create(CommandSender sender) throws ReflectiveOperationException {
if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) { if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) {
throw new IllegalStateException("Native runtime unavailable or probe world already loaded"); throw new IllegalStateException("Native runtime unavailable or probe world already loaded");
@@ -124,6 +193,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
int y = world.getHighestBlockYAt(x, z); int y = world.getHighestBlockYAt(x, z);
if (y < 90 || y > 100 || world.getBlockAt(x, y, z).getType() != Material.LIME_WOOL if (y < 90 || y > 100 || world.getBlockAt(x, y, z).getType() != Material.LIME_WOOL
|| world.getBlockAt(x, y - 3, z).getType() != Material.GREEN_CONCRETE || world.getBlockAt(x, y - 3, z).getType() != Material.GREEN_CONCRETE
|| world.getBlockAt(x, world.getMinHeight(), z).getType() != Material.GREEN_CONCRETE
|| world.getBiome(x, y, z) != Biome.PLAINS) { || world.getBiome(x, y, z) != Biome.PLAINS) {
throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z
+ ": " + world.getBlockAt(x, y, z).getType()); + ": " + world.getBlockAt(x, y, z).getType());
@@ -11,6 +11,15 @@
"block": "minecraft:lime_wool" "block": "minecraft:lime_wool"
} }
] ]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
} }
], ],
"generators": [ "generators": [
@@ -21,7 +21,7 @@
"rockPalette": { "rockPalette": {
"palette": [ "palette": [
{ {
"block": "minecraft:green_concrete" "block": "minecraft:red_concrete"
} }
] ]
} }
@@ -6,6 +6,11 @@ import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.world.IrisCreator; import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.entity.IrisSpawner; import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.world.entity.IrisMarker;
import art.arcane.iris.world.entity.EntitySpawnSeed;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.world.entity.IrisEntitySpawn; import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisEntity; import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.platform.bukkit.BukkitPlatform;
@@ -28,6 +33,7 @@ import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType; import org.bukkit.entity.EntityType;
import org.bukkit.entity.LivingEntity; import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority; import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
@@ -38,9 +44,13 @@ import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException; import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.TreeMap; import java.util.TreeMap;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.logging.Level; import java.util.logging.Level;
@@ -83,7 +93,7 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
@Override @Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length != 1) { if (arguments.length != 1) {
notifySender(sender, "Use /irisspawnqa setup|frogs|zombies|stress|check|surface|cave"); notifySender(sender, "Use /irisspawnqa setup|frogs|zombies|stress|check|seeded|completion|surface|cave");
return true; return true;
} }
try { try {
@@ -93,6 +103,8 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
case "zombies" -> runRegion(sender, () -> spawnZombies(sender)); case "zombies" -> runRegion(sender, () -> spawnZombies(sender));
case "stress" -> runRegion(sender, () -> stress(sender)); case "stress" -> runRegion(sender, () -> stress(sender));
case "check" -> runRegion(sender, () -> check(sender)); case "check" -> runRegion(sender, () -> check(sender));
case "seeded" -> seeded(sender);
case "completion" -> completion(sender);
case "surface" -> view(sender, 67D); case "surface" -> view(sender, 67D);
case "cave" -> view(sender, -46D); case "cave" -> view(sender, -46D);
default -> throw new IllegalArgumentException("Unknown spawning operation"); default -> throw new IllegalArgumentException("Unknown spawning operation");
@@ -147,6 +159,9 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
private void configureWorld(CommandSender sender, World world) { private void configureWorld(CommandSender sender, World world) {
try { try {
world.setDifficulty(Difficulty.NORMAL); world.setDifficulty(Difficulty.NORMAL);
world.setTime(6000L);
world.setStorm(false);
world.setThundering(false);
world.setGameRule(GameRules.SPAWN_MOBS, true); world.setGameRule(GameRules.SPAWN_MOBS, true);
List<CompletableFuture<Void>> chunks = new ArrayList<>(); List<CompletableFuture<Void>> chunks = new ArrayList<>();
for (int chunkX = -1; chunkX <= 1; chunkX++) { for (int chunkX = -1; chunkX <= 1; chunkX++) {
@@ -176,6 +191,11 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
Bukkit.getRegionScheduler().execute(this, world, chunkX, chunkZ, () -> { Bukkit.getRegionScheduler().execute(this, world, chunkX, chunkZ, () -> {
try { try {
chunk.addPluginChunkTicket(this); chunk.addPluginChunkTicket(this);
for (Entity entity : chunk.getEntities()) {
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
entity.remove();
}
}
retained.complete(null); retained.complete(null);
} catch (Throwable error) { } catch (Throwable error) {
retained.completeExceptionally(error); retained.completeExceptionally(error);
@@ -244,12 +264,218 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
} }
} }
private void completion(CommandSender sender) {
World world = requireWorld();
Bukkit.getGlobalRegionScheduler().execute(this, () -> {
world.setGameRule(GameRules.SPAWN_MOBS, false);
runRegion(sender, () -> beginCompletion(sender, world));
});
}
private void beginCompletion(CommandSender sender, World world) {
try {
Engine engine = IrisToolbelt.access(world).getEngine();
Object manager = engine.getWorldManager();
Field field = manager.getClass().getDeclaredField("chunkMaintenance");
field.setAccessible(true);
Object maintenance = field.get(manager);
Method run = maintenance.getClass().getDeclaredMethod("runInitialSpawn", World.class, int.class, int.class, Runnable.class);
run.setAccessible(true);
Field pending = maintenance.getClass().getDeclaredField("markerFlagQueue");
pending.setAccessible(true);
AtomicInteger callbacks = new AtomicInteger();
Runnable callback = () -> {
require(Bukkit.isOwnedByCurrentRegion(world, 0, 0), "Initial callback ran outside its owning region");
callbacks.incrementAndGet();
Chunk chunk = world.getChunkAt(0, 0);
invokeAmbient(runtimeSpawn(chunk, "seeded"), chunk, true);
};
run.invoke(maintenance, world, 0, 0, callback);
awaitSkippedInitial(sender, world, new CompletionRun(engine, maintenance, run, pending, callback, callbacks), 0);
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Could not start initial completion test", failure);
}
}
private void awaitSkippedInitial(CommandSender sender, World world, CompletionRun state, int attempt) {
Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0, task -> {
try {
require(state.callbacks().get() == 0, "Disabled mob spawning consumed the initial pass");
if (!((Set<?>) state.pending().get(state.maintenance())).isEmpty()) {
require(attempt < 100, "Skipped initial pass did not release its pending state");
awaitSkippedInitial(sender, world, state, attempt + 1);
return;
}
Bukkit.getGlobalRegionScheduler().execute(this, () -> {
world.setGameRule(GameRules.SPAWN_MOBS, true);
runRegion(sender, () -> retryInitialCompletion(sender, world, state));
});
} catch (Throwable failure) {
fail(sender, failure);
}
}, 2L);
}
private void retryInitialCompletion(CommandSender sender, World world, CompletionRun state) {
try {
state.run().invoke(state.maintenance(), world, 0, 0, state.callback());
state.run().invoke(state.maintenance(), world, 0, 0, state.callback());
awaitInitialCompletion(sender, world, state, 0);
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Could not retry initial spawning", failure);
}
}
private void awaitInitialCompletion(CommandSender sender, World world, CompletionRun state, int attempt) {
Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0, task -> {
try {
if (state.callbacks().get() == 0 || !((Set<?>) state.pending().get(state.maintenance())).isEmpty()) {
require(attempt < 100, "Initial spawning did not complete after retry");
awaitInitialCompletion(sender, world, state, attempt + 1);
return;
}
require(state.callbacks().get() == 1, "Concurrent initial requests spawned duplicate populations");
state.run().invoke(state.maintenance(), world, 0, 0, state.callback());
Bukkit.getAsyncScheduler().runNow(this, ignored -> {
try {
require(state.engine().getMantle().getMantle().hasFlag(0, 0, MantleFlag.INITIAL_SPAWNED_MARKER), "Initial completion was not persisted");
require(state.callbacks().get() == 1, "Completed initial pass ran again");
evidence(sender, "SPAWN_QA_COMPLETION retry=true callbacks=1 persisted=true owned=true");
} catch (Throwable failure) {
fail(sender, failure);
}
});
} catch (Throwable failure) {
fail(sender, failure);
}
}, 2L);
}
private void seeded(CommandSender sender) {
requireWorld();
List<List<String>> baseline = new ArrayList<>();
sampleInitial(0, false).thenCompose(first -> {
baseline.add(first);
return sampleInitial(1, false);
}).thenCompose(second -> {
baseline.add(second);
return sampleInitial(1, false);
}).thenCompose(repeated -> {
require(baseline.get(1).equals(repeated), "Second chunk changed after reversed generation order");
return sampleInitial(0, false);
}).thenCompose(repeated -> {
require(baseline.get(0).equals(repeated), "First chunk changed after reversed generation order");
return sampleInitial(0, true);
}).thenCompose(marker -> {
baseline.add(marker);
return sampleInitial(1, true);
}).thenCompose(marker -> {
baseline.add(marker);
return sampleInitial(1, true);
}).thenCompose(repeated -> {
require(baseline.get(3).equals(repeated), "Second marker changed after reversed generation order");
return sampleInitial(0, true);
}).whenComplete((repeated, failure) -> {
if (failure != null) {
fail(sender, failure);
return;
}
try {
require(baseline.get(2).equals(repeated), "First marker changed after reversed generation order");
evidence(sender, "SPAWN_QA_SEEDED chunks=2 markers=2 repeats=2 equipment=true attributes=true");
} catch (Throwable error) {
fail(sender, error);
}
});
}
private CompletableFuture<List<String>> sampleInitial(int chunkX, boolean marker) {
CompletableFuture<List<String>> result = new CompletableFuture<>();
World world = requireWorld();
Bukkit.getRegionScheduler().execute(this, world, chunkX, 0, () -> {
try {
Chunk chunk = world.getChunkAt(chunkX, 0);
for (Entity entity : chunk.getEntities()) {
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
entity.remove();
}
}
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
chunk.getBlock(x, 64, z).setType(Material.GRASS_BLOCK, false);
for (int y = 65; y <= 68; y++) {
chunk.getBlock(x, y, z).setType(Material.AIR, false);
}
}
}
RuntimeSpawn spawn = runtimeSpawn(chunk, "seeded");
IrisSpawner definition = spawn.environment().data().getSpawnerLoader().load("qa/seeded");
require(!definition.canSpawn(spawn.engine(), chunkX, 0), "Ongoing weather/time gate unexpectedly allowed spawning");
for (int i = 0; i < 100 + chunkX; i++) {
RNG.r.nextLong();
}
ambientInvocation.set(true);
try {
for (int i = 0; i < 5; i++) {
LivingEntity witness = (LivingEntity) world.spawnEntity(new Location(world, (chunkX << 4) + 8.5, 65, 8.5), EntityType.ZOMBIE);
witness.setCustomName("Population witness");
witness.setAI(false);
witness.setGravity(false);
}
} finally {
ambientInvocation.remove();
}
if (marker) {
invokeInitialMarker(spawn, definition, new IrisPosition((chunkX << 4) + 5, 64, 5));
} else {
invokeAmbient(spawn, chunk, true);
}
List<String> snapshot = new ArrayList<>();
for (Entity entity : chunk.getEntities()) {
String name = entity.getCustomName();
if (!(entity instanceof LivingEntity living) || name == null || !name.startsWith("Seeded ")) {
continue;
}
ItemStack hand = living.getEquipment().getItemInMainHand();
ItemStack helmet = living.getEquipment().getHelmet();
require(hand.hasItemMeta() && hand.getItemMeta().hasAttributeModifiers(), "Spawned gear has no authored attributes");
require(helmet != null && helmet.getType() == Material.IRON_HELMET, "Spawned helmet was not authored equipment");
Location at = entity.getLocation();
snapshot.add(entity.getType() + ":" + at.getX() + ":" + at.getY() + ":" + at.getZ()
+ ":" + HexFormat.of().formatHex(hand.serializeAsBytes())
+ ":" + HexFormat.of().formatHex(helmet.serializeAsBytes()));
}
require(snapshot.size() >= 2 && snapshot.size() <= 4, "Initial population was blocked by runtime gates or cap: " + snapshot.size());
Collections.sort(snapshot);
result.complete(List.copyOf(snapshot));
} catch (Throwable failure) {
result.completeExceptionally(failure);
}
});
return result;
}
private void invokeInitialMarker(RuntimeSpawn spawn, IrisSpawner definition, IrisPosition position) {
ambientInvocation.set(true);
try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) {
Method method = spawn.spawner().getClass().getDeclaredMethod("spawnMarker", IrisPosition.class, IrisSpawner.class, IrisMarker.class, RNG.class, boolean.class);
method.setAccessible(true);
method.invoke(spawn.spawner(), position, definition, null, EntitySpawnSeed.marker(spawn.engine().getSeedManager().getEntity(), position.getX(), position.getY(), position.getZ()), true);
} catch (InvocationTargetException failure) {
throw new IllegalStateException("Initial marker spawning failed", failure.getCause());
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Could not invoke initial marker spawning", failure);
} finally {
ambientInvocation.remove();
}
}
private void spawnFrogs(CommandSender sender) { private void spawnFrogs(CommandSender sender) {
Chunk chunk = requireWorld().getChunkAt(0, 0); Chunk chunk = requireWorld().getChunkAt(0, 0);
RuntimeSpawn spawn = runtimeSpawn(chunk, "frogs"); RuntimeSpawn spawn = runtimeSpawn(chunk, "frogs");
getLogger().info(spawnDiagnostics(spawn, chunk)); getLogger().info(spawnDiagnostics(spawn, chunk));
for (int attempt = 0; attempt < 12; attempt++) { for (int attempt = 0; attempt < 12; attempt++) {
invokeAmbient(spawn, chunk); invokeAmbient(spawn, chunk, false);
require(count(chunk, EntityType.FROG) <= FROG_CAPACITY, "Frog batch exceeded its category capacity"); require(count(chunk, EntityType.FROG) <= FROG_CAPACITY, "Frog batch exceeded its category capacity");
} }
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, require(count(chunk, EntityType.FROG) == FROG_CAPACITY,
@@ -266,7 +492,7 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
require(count(chunk, EntityType.ZOMBIE) == 0, "Cave phase already ran"); require(count(chunk, EntityType.ZOMBIE) == 0, "Cave phase already ran");
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies"); RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
getLogger().info(spawnDiagnostics(spawn, chunk)); getLogger().info(spawnDiagnostics(spawn, chunk));
invokeAmbient(spawn, chunk); invokeAmbient(spawn, chunk, false);
firstZombieBatch = count(chunk, EntityType.ZOMBIE); firstZombieBatch = count(chunk, EntityType.ZOMBIE);
require(firstZombieBatch == ZOMBIE_CAPACITY, require(firstZombieBatch == ZOMBIE_CAPACITY,
"Authored seven-zombie batch was not clamped to capacity two: " + spawnDiagnostics(spawn, chunk)); "Authored seven-zombie batch was not clamped to capacity two: " + spawnDiagnostics(spawn, chunk));
@@ -280,7 +506,7 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
Chunk chunk = requireWorld().getChunkAt(0, 0); Chunk chunk = requireWorld().getChunkAt(0, 0);
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies"); RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
for (int attempt = 0; attempt < 50; attempt++) { for (int attempt = 0; attempt < 50; attempt++) {
invokeAmbient(spawn, chunk); invokeAmbient(spawn, chunk, false);
require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Saturated zombie category exceeded capacity"); require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Saturated zombie category exceeded capacity");
} }
evidence(sender, "SPAWN_QA_STRESS attempts=50 zombies=" + count(chunk, EntityType.ZOMBIE)); evidence(sender, "SPAWN_QA_STRESS attempts=50 zombies=" + count(chunk, EntityType.ZOMBIE));
@@ -371,10 +597,10 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
return diagnostics.toString(); return diagnostics.toString();
} }
private void invokeAmbient(RuntimeSpawn spawn, Chunk chunk) { private void invokeAmbient(RuntimeSpawn spawn, Chunk chunk, boolean initial) {
ambientInvocation.set(true); ambientInvocation.set(true);
try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) { try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) {
spawn.method().invoke(spawn.spawner(), chunk, false, spawn.environment()); spawn.method().invoke(spawn.spawner(), chunk, initial, spawn.environment());
} catch (InvocationTargetException failure) { } catch (InvocationTargetException failure) {
throw new IllegalStateException("Iris ambient spawning failed", failure.getCause()); throw new IllegalStateException("Iris ambient spawning failed", failure.getCause());
} catch (ReflectiveOperationException failure) { } catch (ReflectiveOperationException failure) {
@@ -467,6 +693,9 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
} }
} }
private record CompletionRun(Engine engine, Object maintenance, Method run, Field pending, Runnable callback, AtomicInteger callbacks) {
}
private record RuntimeSpawn(Engine engine, Object spawner, Method method, BiomeEnvironment environment) { private record RuntimeSpawn(Engine engine, Object spawner, Method method, BiomeEnvironment environment) {
} }
} }
@@ -0,0 +1,26 @@
{
"name": "Seeded population",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:grass_block"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 64
}
],
"entitySpawners": [
"qa/seeded"
]
}
@@ -0,0 +1,29 @@
{
"type": "minecraft:husk",
"reason": "NATURAL",
"surface": "LAND",
"ai": false,
"aware": false,
"gravity": false,
"customName": "Seeded husk",
"invulnerable": true,
"helmet": {
"type": "IRON_HELMET",
"minDurability": 0.1,
"maxDurability": 0.9
},
"mainHand": {
"type": "IRON_SWORD",
"minDurability": 0.1,
"maxDurability": 0.9,
"attributes": [
{
"name": "seeded_damage",
"attribute": "minecraft:attack_damage",
"minAmount": 1.25,
"maxAmount": 6.75,
"chance": 1
}
]
}
}
@@ -0,0 +1,29 @@
{
"type": "minecraft:zombie",
"reason": "NATURAL",
"surface": "LAND",
"ai": false,
"aware": false,
"gravity": false,
"customName": "Seeded zombie",
"invulnerable": true,
"helmet": {
"type": "IRON_HELMET",
"minDurability": 0.1,
"maxDurability": 0.9
},
"mainHand": {
"type": "IRON_SWORD",
"minDurability": 0.1,
"maxDurability": 0.9,
"attributes": [
{
"name": "seeded_damage",
"attribute": "minecraft:attack_damage",
"minAmount": 1.25,
"maxAmount": 6.75,
"chance": 1
}
]
}
}
@@ -0,0 +1,33 @@
{
"group": "NORMAL",
"maxEntitiesPerChunk": 4,
"allowedLightLevels": {
"min": 0,
"max": 0
},
"weather": "DOWNFALL",
"timeBlock": {
"startHour": 20,
"endHour": 3
},
"maximumRate": {
"amount": 1,
"per": {
"days": 1
}
},
"initialSpawns": [
{
"entity": "qa/seeded-zombie",
"rarity": 1,
"minSpawns": 2,
"maxSpawns": 4
},
{
"entity": "qa/seeded-husk",
"rarity": 2,
"minSpawns": 2,
"maxSpawns": 4
}
]
}
@@ -8,7 +8,8 @@ export default {
context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response) context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response)
return response return response
} }
const entities = name => Object.values(context.bot.entities).filter(entity => entity.name === name) const entities = name => Object.values(context.bot.entities).filter(entity => entity.name === name
&& entity.position.x >= 0 && entity.position.x < 16 && entity.position.z >= 0 && entity.position.z < 16)
await context.step('prepare the synthetic Iris world and dark cave', async () => { await context.step('prepare the synthetic Iris world and dark cave', async () => {
await command('setup', 'SPAWN_QA_SETUP world=iris:spawn_qa seed=78264193', 180000) await command('setup', 'SPAWN_QA_SETUP world=iris:spawn_qa seed=78264193', 180000)
await wait(() => Math.abs(context.bot.entity.position.y - 67) < 1, 'Fixture world teleport') await wait(() => Math.abs(context.bot.entity.position.y - 67) < 1, 'Fixture world teleport')
@@ -0,0 +1,32 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 96
}
]
}
@@ -0,0 +1,28 @@
{
"name": "Noise Sources",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
}
}
@@ -0,0 +1,3 @@
{
"expression": "0.5 + sin(x * 0.15) * 0.25 + cos(y * 0.13) * 0.25"
}
@@ -0,0 +1,22 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"expression": "terrain"
}
},
{
"seed": 311,
"style": {
"style": "CLOVER_HERMITE",
"zoom": 0.1
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,48 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-noise',
description: 'Verify expression and interpolated noise produce varied, bounded terrain visible to a player.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Noise generation requires an isolated instance')
await context.step('create a world with expression and Clover noise', async () => {
const response = await context.command('/iris create name=noise_probe type=noise-fixture seed=78264193',
/Successfully created your world|Failed to create/, 180000)
context.expect(response.includes('Successfully created your world'), response)
})
await context.step('client observes bounded terrain with varying heights', async () => {
let columns = 0
let minimum = Infinity
let maximum = -Infinity
await context.waitUntil(() => {
columns = 0
minimum = Infinity
maximum = -Infinity
const center = context.bot.entity.position.floored()
for (let x = -8; x <= 8; x++) {
for (let z = -8; z <= 8; z++) {
let surface = null
for (let y = 60; y <= 100; y++) {
const position = center.offset(x, y - center.y, z)
const block = context.bot.blockAt(position)
if (!block) return false
if (block.name === 'lime_wool') {
if (surface !== null || context.bot.blockAt(position.offset(0, 1, 0))?.name !== 'air') return false
surface = y
}
}
if (surface === null || surface < 64 || surface > 96) return false
columns++
minimum = Math.min(minimum, surface)
maximum = Math.max(maximum, surface)
}
}
return columns === 289 && maximum - minimum >= 3
}, { label: '289 noise columns with bounded, varied surfaces', timeoutMs: 60000, intervalMs: 250 })
context.expect(columns === 289 && maximum - minimum >= 3, 'Client verified noise terrain', { columns, minimum, maximum })
context.report.noiseTerrain = { columns, minimum, maximum }
})
}
}
@@ -0,0 +1,24 @@
export default {
name: 'iris-seeded-entities',
description: 'Replay initial chunk and marker populations in reversed order, comparing positions and complete equipment data.',
async run(context) {
const command = async (operation, expected, timeoutMs) => {
const response = await context.command(`/irisspawnqa ${operation}`, /SPAWN_QA_[A-Z]+(?:\s|$)/, timeoutMs)
context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response)
}
await context.step('prepare a controlled Iris world', async () => {
await command('setup', 'SPAWN_QA_SETUP', 180000)
})
await context.step('replay initial mobs despite unrelated randomness and live spawn restrictions', async () => {
await command('seeded', 'SPAWN_QA_SEEDED chunks=2 markers=2 repeats=2 equipment=true attributes=true', 60000)
await context.waitUntil(() => Object.values(context.bot.entities).some(entity => entity.name === 'zombie' || entity.name === 'husk'), {
label: 'Generated mobs reached the client', timeoutMs: 10000, intervalMs: 100
})
context.report.seededEntities = { chunks: 2, markers: 2, repeats: 2, positions: true, equipmentBytes: true, attributeIds: true }
})
await context.step('retry a skipped initial pass and persist completion once', async () => {
await command('completion', 'SPAWN_QA_COMPLETION retry=true callbacks=1 persisted=true owned=true', 30000)
context.report.seededEntities.completionRetry = true
})
}
}
@@ -0,0 +1,45 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:stone"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 64
}
],
"objects": [
{
"place": [
"object"
],
"chance": 1,
"density": 4,
"mode": "CENTER_HEIGHT",
"rotation": {
"enabled": false
}
}
]
}
@@ -0,0 +1,66 @@
{
"name": "Structure Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:stone"
}
]
},
"structures": [
{
"structures": [
"surface"
],
"placementId": "surface",
"distribution": "DENSITY",
"density": 1,
"anchor": "SURFACE",
"terrain": {
"mode": "PRESERVE"
},
"stilt": {
"maxDepth": 16,
"palette": {
"palette": [
{
"block": "minecraft:diamond_block"
}
]
}
}
},
{
"structures": [
"buried"
],
"placementId": "buried",
"distribution": "DENSITY",
"density": 1,
"anchor": "HEIGHT_BAND",
"minHeight": 24,
"maxHeight": 24,
"terrain": {
"mode": "BORE"
}
}
]
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,8 @@
{
"object": "buried",
"connectors": [],
"rotatable": false,
"rules": {
"terminal": true
}
}
@@ -0,0 +1,8 @@
{
"object": "surface",
"connectors": [],
"rotatable": false,
"rules": {
"terminal": true
}
}
@@ -0,0 +1,8 @@
{
"pieces": [
{
"piece": "buried",
"weight": 1
}
]
}
@@ -0,0 +1,8 @@
{
"pieces": [
{
"piece": "surface",
"weight": 1
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,6 @@
{
"startPool": "buried",
"maxDepth": 1,
"maxSizeChunks": 1,
"placeMode": "STRUCTURE_PIECE"
}
@@ -0,0 +1,6 @@
{
"startPool": "surface",
"maxDepth": 1,
"maxSizeChunks": 1,
"placeMode": "STRUCTURE_PIECE"
}
@@ -0,0 +1,52 @@
import { readFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import path from 'node:path'
export default {
name: 'iris-structures',
description: 'Verify surface foundations, buried rooms, and waterlogged object blocks in generated chunks.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Structure generation requires an isolated instance')
const world = context.options.command ?? 'structure_probe'
context.expect(/^[a-z0-9_]+$/.test(world), 'Fixture world name must be a simple identifier')
await context.step('create the structure fixture world', async () => {
const response = await context.command(`/iris create name=${world} type=structure-fixture seed=78264193`,
/World ready|Failed to create/, 180000)
context.expect(response.includes('World ready'), response)
})
await context.step('observe structures and objects above and below terrain', async () => {
await context.waitUntil(() => {
if (Math.abs(context.bot.entity.position.x) > 32 || Math.abs(context.bot.entity.position.z) > 32) return false
for (let x = -16; x < 32; x += 16) {
for (let z = -16; z < 32; z += 16) {
for (let y = 16; y <= 80; y += 16) {
if (!context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z))) return false
}
}
}
return true
}, { label: 'Complete structure fixture chunks', timeoutMs: 60000, intervalMs: 100 })
const digest = createHash('sha256')
const counts = { emerald_block: 0, diamond_block: 0, blue_concrete: 0, gold_block: 0, oak_slab: 0 }
for (let x = -16; x < 32; x++) {
for (let z = -16; z < 32; z++) {
for (let y = 16; y <= 80; y++) {
const block = context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z))
context.expect(block !== null, 'Every sampled structure block must be loaded')
digest.update(`${block.stateId}\n`)
if (Object.hasOwn(counts, block.name)) counts[block.name]++
if (block.name === 'emerald_block') context.expect(y > 64, 'Surface platform must be above terrain')
if (block.name === 'blue_concrete') context.expect(y < 40, 'Buried room must remain underground')
if (block.name === 'oak_slab') context.expect(block.getProperties().waterlogged === false,
'Object slabs placed on dry ground must clear waterlogging')
}
}
}
for (const [block, count] of Object.entries(counts)) {
context.expect(count > 0, `Generated fixture must contain ${block}`, { count })
}
context.report.structures = { counts, hash: digest.digest('hex') }
})
}
}
@@ -0,0 +1,71 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { setup, openStudio, closeStudio, sampleTerrain, readJson, writeJson,
logPosition, logSince } from './studio-support.mjs'
export default {
name: 'iris-studio-hotload-lifecycle',
description: 'Generate cold chunks during Studio edits and close after saving an edit.',
async run(context) {
const fixture = await setup(context)
const biomeFile = path.join(fixture.pack, 'biomes', 'flat.json')
const original = await readFile(biomeFile, 'utf8')
const logOffset = await logPosition(fixture)
let active
let x = 16384
try {
active = await openStudio(context, fixture)
await sampleTerrain(context, fixture, 0, fixture.state.material)
for (let cycle = 0; cycle < 3; cycle++) {
await context.step(`Load cold chunks during hotload ${cycle + 1}`, async () => {
const manifestFile = fixture.manifestPath(active.key)
const before = await readJson(manifestFile)
const biome = await readJson(biomeFile)
const material = cycle % 2 === 0 ? 'gold_block' : 'diamond_block'
biome.name = `Lifecycle terrain ${cycle + 1}`
biome.layers[0].palette[0].block = `minecraft:${material}`
const startedAt = Date.now()
await writeJson(biomeFile, biome)
x += 512
await context.command(`/minecraft:tp @s ${x + 0.5} 101 0.5`, /Teleported/i, 15000)
const after = await fixture.wait(async () => {
const manifest = await readJson(manifestFile)
return manifest.activeActivationId !== before.activeActivationId ? manifest : false
}, 'Hotload did not complete while the player requested fresh chunks', 90000)
const activationDurationMs = Date.now() - startedAt
await fixture.wait(() => Math.abs(context.bot.entity.position.x - x - 0.5) < 1,
'Cold chunk teleport did not arrive after hotload', 30000)
context.report.iris.activations.push({ before: before.activeActivationId,
after: after.activeActivationId, material, coldChunkRequests: 1, activationDurationMs })
x += 2048
await sampleTerrain(context, fixture, x, material)
})
}
await context.step('Close Studio while a saved edit is activating', async () => {
const biome = await readJson(biomeFile)
biome.name = 'Lifecycle close terrain'
await writeJson(biomeFile, biome)
const pending = await fixture.wait(async () => {
const manifest = await readJson(fixture.manifestPath(active.key))
return manifest.pendingActivationId !== null ? manifest : false
}, 'The saved close edit did not enter activation', 90000)
context.report.iris.closePendingActivationId = pending.pendingActivationId
await closeStudio(context, fixture, active)
active = undefined
await context.command('/minecraft:seed', /Seed:/i, 10000)
})
await context.step('Reopen the edited pack and retain usable generation', async () => {
active = await openStudio(context, fixture)
await sampleTerrain(context, fixture, 0, 'gold_block')
await closeStudio(context, fixture, active)
active = undefined
})
const log = await logSince(fixture, logOffset)
context.expect(!/Studio generation update failed|Unable to (capture terrain|save the terrain boundary)|ClosedChannelException|Chunk system error|server has stopped responding|could not acquire its engine runtime|Failed to list files/i.test(log),
'Studio lifecycle emitted a generation, snapshot, storage, or watchdog failure')
} finally {
if (active) await closeStudio(context, fixture, active)
await writeFile(biomeFile, original)
}
}
}
@@ -0,0 +1,74 @@
import { readFile, stat } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import path from 'node:path'
export default {
name: 'iris-studio-open',
description: 'Measure repeated Studio entry and verify visible terrain, spectator mode, and world cleanup.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Studio entry requires an isolated instance')
const match = /^([a-z0-9_-]+):([1-4])$/.exec(context.options.command ?? 'overworld:2')
context.expect(match, 'Expected pack:cycles with one to four cycles')
context.report.studioOpen = []
for (let cycle = 0; cycle < Number(match[2]); cycle++) {
await context.step(`open and close Studio ${cycle + 1}`, async () => {
const before = new Set((await context.observe()).worlds.map(world => world.id))
const start = Date.now()
let world
let center
await context.command(`/iris studio open ${match[1]} seed=1337`)
await context.waitUntil(async () => {
const observation = await context.observe()
const player = observation.players.find(entry => entry.username === context.bot.username)
world = observation.worlds.find(entry => entry.id === player?.world)
if (!world || before.has(world.id) || !/iris-[0-9a-f-]{36}$/.test(world.name)) return false
center = context.bot.entity.position.floored()
return world.loadedChunks > 0 && context.bot.blockAt(center) !== null && context.bot.game.gameMode === 'spectator'
}, { label: 'Studio arrival with terrain and spectator mode', timeoutMs: 180000, intervalMs: 100 })
const arrivalMs = Date.now() - start
context.expect(Number.isInteger(context.bot.game.minY) && Number.isInteger(context.bot.game.height),
'Studio dimension must report its vertical range')
await context.waitUntil(() => {
for (let x = -1; x <= 1; x++) {
for (let z = -1; z <= 1; z++) {
for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y += 16) {
if (context.bot.blockAt(center.offset(x, y - center.y, z)) === null) return false
}
}
}
return true
}, { label: 'Complete neighboring Studio columns', timeoutMs: 60000, intervalMs: 100 })
const digest = createHash('sha256')
let solid = 0
for (let x = -1; x <= 1; x++) {
for (let z = -1; z <= 1; z++) {
for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y++) {
const block = context.bot.blockAt(center.offset(x, y - center.y, z))
context.expect(block !== null, 'Studio entry column must be available')
digest.update(`${block.name}\n`)
if (!['air', 'cave_air', 'void_air', 'water', 'lava'].includes(block.name)) solid++
}
}
}
context.expect(solid > 0, 'Studio entry must contain solid terrain')
context.report.studioOpen.push({ cycle, arrivalMs, world: world.name, x: center.x, z: center.z,
terrainHash: digest.digest('hex'), solid })
const closed = await context.command('/iris studio close', /^Studio closed\.$|Studio close failed:/, 180000)
context.expect(closed === 'Studio closed.', closed)
await context.waitUntil(async () => !(await context.observe()).worlds.some(entry => entry.id === world.id),
{ label: 'Studio world unload', timeoutMs: 90000, intervalMs: 100 })
const transientName = /iris-[0-9a-f-]{36}$/.exec(world.name)[0]
const properties = await readFile(path.join(context.server.directory, 'server.properties'), 'utf8')
const level = /^level-name=(.+)$/m.exec(properties)?.[1].trim() ?? 'world'
const directory = path.join(context.server.directory, level, 'dimensions', 'iris', transientName)
await context.waitUntil(async () => {
try { await stat(directory); return false } catch (error) {
if (error.code === 'ENOENT') return true
throw error
}
}, { label: 'Studio directory cleanup', timeoutMs: 90000, intervalMs: 100 })
})
}
}
}
@@ -0,0 +1,98 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
const worldName = 'lifecycle_probe'
const worldKey = `iris:${worldName}`
const storageName = `world_iris_${worldName}`
export default {
name: 'iris-world-lifecycle',
description: 'Create, enter, evacuate, reload, and remove an Iris world through the player commands.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'World lifecycle requires an isolated instance')
const wait = (predicate, label, timeoutMs = 180000) => context.waitUntil(predicate, {
label, timeoutMs, intervalMs: 250
})
const playerWorld = async () => {
const observation = await context.observe()
const player = observation.players.find(entry => entry.username === context.bot.username)
return observation.worlds.find(entry => entry.id === player?.world)?.name
}
const status = async () => {
const response = await context.command('/irislifecycle lifecycle-status', /IRIS_LIFECYCLE |NATIVE_FAILED/, 10000)
context.expect(!response.includes('NATIVE_FAILED'), response)
return response
}
const settledStatus = async expected => {
const response = await status()
return response.includes(expected) && response.includes('idle=true')
}
const assertEvacuated = async label => {
let destination
await wait(async () => {
destination = await playerWorld()
return Boolean(destination && destination !== storageName)
}, label, 30000)
context.expect(destination && destination !== storageName, label, { destination })
}
const assertSafeEntry = async () => {
await wait(async () => (await playerWorld()) === storageName, 'Player entered the lifecycle world')
await wait(() => {
const feet = context.bot.entity.position.floored()
const floor = context.bot.blockAt(feet.offset(0, -1, 0))
const body = context.bot.blockAt(feet)
const head = context.bot.blockAt(feet.offset(0, 1, 0))
return context.bot.entity.onGround && floor?.name === 'lime_wool'
&& body?.boundingBox === 'empty' && head?.boundingBox === 'empty'
}, 'Client sees solid terrain below unobstructed player entry', 30000)
const response = await context.command('/irislifecycle lifecycle-entry', /IRIS_LIFECYCLE_ENTRY|NATIVE_FAILED/, 10000)
context.expect(response.includes(`IRIS_LIFECYCLE_ENTRY world=${worldKey} seed=78264193 floor=lime_wool safe=true`), response)
}
await context.step('confirm disposable world is absent', async () => {
const response = await status()
context.expect(response.includes('loaded=false registered=false configured=false disk=false quarantines=0 cleanup=deleted idle=true'), response)
await context.command('/minecraft:gamemode survival @s')
await wait(() => context.bot.game.gameMode === 'survival', 'Survival mode', 10000)
})
await context.step('create through Iris and automatically enter safe terrain', async () => {
await context.command(`/iris create name=${worldName} type=native-terrain seed=78264193`)
await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'Created world registration and storage')
await assertSafeEntry()
})
await context.step('unload while the player is inside and verify evacuation', async () => {
context.expect((await playerWorld()) === storageName, 'Player must be inside the world before unload')
await context.command(`/iris unloadWorld ${worldKey}`)
await wait(async () => await settledStatus('loaded=false registered=true configured=true disk=true'), 'World unloaded with storage preserved')
await assertEvacuated('Unload evacuated the player to a loaded world')
})
await context.step('reload the saved world and enter it again', async () => {
await context.command(`/iris loadWorld ${worldName}`)
await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'World reloaded')
await context.command(`/iris tp ${worldKey}`)
await assertSafeEntry()
})
await context.step('remove the occupied world and verify disk cleanup', async () => {
await context.command(`/iris remove world=${worldName} delete=true`)
let finalStatus
await wait(async () => {
finalStatus = await status()
return finalStatus.includes('loaded=false registered=false configured=false disk=false')
&& /cleanup=(deleted|queued)\b/.test(finalStatus) && finalStatus.includes('idle=true')
}, 'Removal detached registrations and deleted or queued quarantine')
await assertEvacuated('Removal evacuated the player to a loaded world')
context.report.worldLifecycle = { world: worldKey, cleanup: /cleanup=(deleted|queued)\b/.exec(finalStatus)[1] }
})
if (process.env.IRIS_QA_PACK_URL) {
await context.step('download and overwrite the fixture pack through Iris', async () => {
const response = await context.command(
`/iris download link=${process.env.IRIS_QA_PACK_URL} overwrite=true`,
/Iris pack 'native-terrain' installed|Iris pack download failed\./,
120000
)
context.expect(response.includes("Iris pack 'native-terrain' installed"), response)
})
}
}
}
@@ -20,33 +20,20 @@ import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class PaperPluginMetadataTest { public class PaperPluginMetadataTest {
private static final List<String> OPTIONAL_PLUGIN_IDS = List.of( private static final List<String> JOINED_PLUGIN_IDS = List.of(
"PlaceholderAPI", "PlaceholderAPI",
"CraftEngine", "CraftEngine",
"Nexo", "Nexo",
"Oraxen", "Oraxen",
"ItemsAdder", "ItemsAdder",
"SCore", "SCore",
"ExecutableItems",
"MythicLib",
"MMOItems", "MMOItems",
"eco", "eco",
"EcoItems",
"MythicMobs", "MythicMobs",
"MythicCrucible", "MythicCrucible",
"KGenerators", "KGenerators",
"Multiverse-Core",
"WorldEdit" "WorldEdit"
); );
private static final List<String> JOINED_PLUGIN_IDS = OPTIONAL_PLUGIN_IDS.stream()
.filter(pluginId -> !"ExecutableItems".equals(pluginId) && !"Multiverse-Core".equals(pluginId))
.toList();
private static final List<String> UNORDERED_PLUGIN_IDS = List.of(
"MythicLib", "MMOItems", "MythicMobs", "MythicCrucible");
private static final List<String> BUKKIT_SOFT_DEPEND_IDS = OPTIONAL_PLUGIN_IDS.stream()
.filter(pluginId -> !"Multiverse-Core".equals(pluginId))
.filter(pluginId -> !UNORDERED_PLUGIN_IDS.contains(pluginId))
.toList();
@Test @Test
public void paperMetadataDeclaresBootstrapAndFoliaSupport() throws Exception { public void paperMetadataDeclaresBootstrapAndFoliaSupport() throws Exception {
@@ -75,10 +62,13 @@ public class PaperPluginMetadataTest {
+ " description: Allows survival players to fell Iris-managed trees with an axe.\n" + " description: Allows survival players to fell Iris-managed trees with an axe.\n"
+ " default: op")); + " default: op"));
for (String pluginId : JOINED_PLUGIN_IDS) { for (String pluginId : JOINED_PLUGIN_IDS) {
String loadOrder = UNORDERED_PLUGIN_IDS.contains(pluginId) ? "OMIT" : "BEFORE"; assertTrue(metadata.contains(optionalDependencyBlock(pluginId, "OMIT", true)));
assertTrue(metadata.contains(optionalDependencyBlock(pluginId, loadOrder, true)));
} }
assertTrue(metadata.contains(optionalDependencyBlock("ExecutableItems", "BEFORE", false))); assertFalse(metadata.contains(" ExecutableItems:"));
assertFalse(metadata.contains(" EcoItems:"));
assertFalse(metadata.contains(" MythicLib:"));
assertEquals(1, metadata.lines().filter(line -> line.equals(" load: AFTER")).count());
assertEquals(0, metadata.lines().filter(line -> line.equals(" load: BEFORE")).count());
assertTrue(metadata.contains(optionalDependencyBlock("Multiverse-Core", "AFTER", true))); assertTrue(metadata.contains(optionalDependencyBlock("Multiverse-Core", "AFTER", true)));
} }
@@ -95,7 +85,7 @@ public class PaperPluginMetadataTest {
Map<String, Map<String, Object>> commands = metadata.getCommands(); Map<String, Map<String, Object>> commands = metadata.getCommands();
assertTrue(commands.containsKey("iris")); assertTrue(commands.containsKey("iris"));
assertEquals(List.of("ir", "irs"), commands.get("iris").get("aliases")); assertEquals(List.of("ir", "irs"), commands.get("iris").get("aliases"));
assertEquals(BUKKIT_SOFT_DEPEND_IDS, metadata.getSoftDepend()); assertTrue(metadata.getSoftDepend().isEmpty());
assertEquals(List.of("Multiverse-Core"), metadata.getLoadBeforePlugins()); assertEquals(List.of("Multiverse-Core"), metadata.getLoadBeforePlugins());
Permission all = metadata.getPermissions().stream() Permission all = metadata.getPermissions().stream()
.filter(permission -> "iris.all".equals(permission.getName())) .filter(permission -> "iris.all".equals(permission.getName()))
@@ -0,0 +1,126 @@
package art.arcane.iris.command;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.Iris;
import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.File;
import java.nio.file.Files;
import java.util.List;
import java.lang.reflect.Method;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class CommandPackValidationPublicationTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@After
public void clearValidation() {
PackValidationRegistry.clear();
}
@Test
public void changedCachedPackCannotBePersistedAsFreshValidation() throws Exception {
File packs = temporaryFolder.newFolder("packs");
File root = new File(packs, "terrain");
Files.createDirectories(root.toPath());
File content = new File(root, "dimension.json");
Files.writeString(content.toPath(), "original");
File cacheFile = new File(temporaryFolder.getRoot(), "validation-cache.json");
Iris previousPlugin = Iris.instance;
Iris plugin = mock(Iris.class);
Iris.instance = plugin;
when(plugin.getDataFile("cache", "pack-validation.json")).thenReturn(cacheFile);
try (MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<PackValidationCache> cache = mockStatic(PackValidationCache.class, CALLS_REAL_METHODS)) {
cache.when(PackValidationCache::contextFingerprint).thenReturn("context");
PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
PackValidationRegistry.publish(valid);
PackValidationRegistry.publish(root.toPath(), valid,
PackFingerprints.computePackTreeFingerprint(root), "context");
Files.writeString(content.toPath(), "changed");
Method persist = CommandPack.class.getDeclaredMethod("persistValidationCache", File.class);
persist.setAccessible(true);
persist.invoke(new CommandPack(), packs);
assertFalse(cacheFile.exists());
Files.writeString(content.toPath(), "original");
persist.invoke(new CommandPack(), packs);
assertTrue(cacheFile.exists());
assertTrue(PackValidationCache.load(cacheFile.toPath(),
PackFingerprints.computePackContentSnapshot(packs).content(), "context",
List.of(root.getName())).orElseThrow().getFirst().isLoadable());
} finally {
Iris.instance = previousPlugin;
}
}
@Test
public void commandPublishesCanonicalRootValidation() throws Exception {
File root = temporaryFolder.newFolder("terrain");
PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(root)).thenReturn(valid);
assertSame(valid, new CommandPack().runValidate(mock(VolmitSender.class), root));
assertSame(valid, PackValidationRegistry.get(root.toPath()));
assertSame(valid, PackValidationRegistry.get(root.getName()));
}
}
@Test
public void mutationDuringValidationCannotRepublishStaleResult() throws Exception {
File root = temporaryFolder.newFolder("terrain");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(root.toPath())) {
return new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
}
});
assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable());
assertNull(PackValidationRegistry.get(root.toPath()));
assertNull(PackValidationRegistry.get(root.getName()));
}
}
@Test
public void externalEditDuringValidationPublishesBlockedResult() throws Exception {
File root = temporaryFolder.newFolder("terrain");
File content = new File(root, "dimension.json");
Files.writeString(content.toPath(), "original");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
Files.writeString(content.toPath(), "changed");
return new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
});
assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable());
assertFalse(PackValidationRegistry.get(root.toPath()).isLoadable());
}
}
}
@@ -5,19 +5,66 @@ import art.arcane.iris.generation.runtime.IrisTelemetrySnapshot;
import art.arcane.iris.generation.runtime.EngineTelemetrySnapshot; import art.arcane.iris.generation.runtime.EngineTelemetrySnapshot;
import art.arcane.volmlib.integration.IntegrationMetricGroup; import art.arcane.volmlib.integration.IntegrationMetricGroup;
import art.arcane.volmlib.integration.IntegrationMetricSample; import art.arcane.volmlib.integration.IntegrationMetricSample;
import art.arcane.volmlib.integration.IntegrationMetricSnapshot;
import art.arcane.volmlib.integration.IntegrationSnapshotProvider;
import art.arcane.volmlib.integration.IntegrationMetricSchema; import art.arcane.volmlib.integration.IntegrationMetricSchema;
import org.junit.Test; import org.junit.Test;
import org.bukkit.Bukkit;
import org.bukkit.plugin.ServicesManager;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class IrisIntegrationServiceTest { public class IrisIntegrationServiceTest {
@Test
public void disableClearsPublishedSnapshots() {
IrisIntegrationService service = new IrisIntegrationService(() -> IrisTelemetrySnapshot.EMPTY);
Set<String> keys = Set.of(IntegrationMetricSchema.IRIS_WORLD_COUNT);
service.snapshotMetrics(keys);
service.publishSnapshots();
long generation = service.snapshotMetrics(keys).generation();
try (MockedStatic<Bukkit> bukkit = Mockito.mockStatic(Bukkit.class)) {
bukkit.when(Bukkit::getServicesManager).thenReturn(Mockito.mock(ServicesManager.class));
service.onDisable();
}
assertTrue(service.snapshotMetrics(keys).samples().isEmpty());
assertTrue(service.snapshotMetrics(keys).generation() > generation);
}
@Test
public void snapshotRequestsDoNotCollectAndKeepOriginalTelemetryTime() {
long sampledAt = System.currentTimeMillis() - 5_000L;
IrisTelemetrySnapshot source = telemetry(List.of(), IrisTelemetrySnapshot.PregenSnapshot.INACTIVE, sampledAt);
AtomicInteger reads = new AtomicInteger();
IrisIntegrationService service = new IrisIntegrationService(() -> {
reads.incrementAndGet();
return source;
});
Set<String> keys = Set.of(IntegrationMetricSchema.IRIS_WORLD_COUNT);
assertTrue(service.capabilities().contains(IntegrationSnapshotProvider.CAPABILITY));
service.publishSnapshots();
assertEquals(0, reads.get());
assertTrue(service.snapshotMetrics(keys).samples().isEmpty());
assertEquals(0, reads.get());
service.publishSnapshots();
IntegrationMetricSnapshot publication = service.snapshotMetrics(keys);
assertEquals(1, reads.get());
assertEquals(sampledAt, publication.samples().get(IntegrationMetricSchema.IRIS_WORLD_COUNT).sampledAtMs());
assertTrue(publication.capturedAtMs() >= sampledAt);
assertEquals(publication, service.snapshotMetrics(keys));
assertEquals(1, reads.get());
}
@Test @Test
public void unavailableTelemetryDoesNotPublishFalseZeroes() { public void unavailableTelemetryDoesNotPublishFalseZeroes() {
IrisIntegrationService service = new IrisIntegrationService(() -> IrisTelemetrySnapshot.EMPTY); IrisIntegrationService service = new IrisIntegrationService(() -> IrisTelemetrySnapshot.EMPTY);
@@ -0,0 +1,103 @@
package art.arcane.iris.integration;
import art.arcane.iris.Iris;
import art.arcane.volmlib.util.bukkit.papi.PlaceholderRegistration;
import org.bukkit.Bukkit;
import org.bukkit.event.HandlerList;
import org.bukkit.event.server.PluginDisableEvent;
import org.bukkit.event.server.PluginEnableEvent;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginManager;
import org.junit.Test;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.logging.Logger;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class IrisPapiLifecycleTest {
@Test
public void lateEnableRegistersOnceAndReattachesAfterDisable() throws Exception {
Iris iris = plugin(true);
Plugin placeholders = namedPlugin("PlaceholderAPI");
PluginManager plugins = mock(PluginManager.class);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<HandlerList> handlers = mockStatic(HandlerList.class);
MockedStatic<PlaceholderRegistration> availability = mockStatic(PlaceholderRegistration.class);
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class);
MockedConstruction<PlaceholderRegistration> registrations = mockConstruction(PlaceholderRegistration.class)) {
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
availability.when(PlaceholderRegistration::isPlaceholderApiEnabled).thenReturn(true);
installer.when(() -> IrisPapiInstaller.install(any(), any(), any())).thenReturn(true);
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
assertEquals(1, registrations.constructed().size());
verify(plugins).registerEvents(any(IrisPapiListener.class), eq(iris));
iris.onPlaceholderApiDisable(new PluginDisableEvent(namedPlugin("Unrelated")));
verify(registrations.constructed().getFirst(), never()).unregister();
iris.onPlaceholderApiDisable(new PluginDisableEvent(placeholders));
verify(registrations.constructed().getFirst()).unregister();
handlers.verify(() -> HandlerList.unregisterAll(any(IrisPapiListener.class)));
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
assertEquals(2, registrations.constructed().size());
verify(plugins, times(2)).registerEvents(any(IrisPapiListener.class), eq(iris));
}
}
@Test
public void enableEventsCannotRegisterBeforeIrisIsReadyOrDuringShutdown() throws Exception {
Iris iris = plugin(false);
Plugin placeholders = namedPlugin("PlaceholderAPI");
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class)) {
iris.onPlaceholderApiEnable(new PluginEnableEvent(placeholders));
installer.verifyNoInteractions();
}
}
@Test
public void unrelatedEnableEventsDoNotRegisterPlaceholders() throws Exception {
Iris iris = plugin(true);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
MockedStatic<IrisPapiInstaller> installer = mockStatic(IrisPapiInstaller.class)) {
iris.onPlaceholderApiEnable(new PluginEnableEvent(namedPlugin("Unrelated")));
installer.verifyNoInteractions();
}
}
private static Iris plugin(boolean active) throws ReflectiveOperationException {
Iris iris = mock(Iris.class);
when(iris.getLogger()).thenReturn(Logger.getAnonymousLogger());
doCallRealMethod().when(iris).onPlaceholderApiEnable(any());
doCallRealMethod().when(iris).onPlaceholderApiDisable(any());
Field lifecycle = Iris.class.getDeclaredField("papiLifecycleActive");
lifecycle.setAccessible(true);
lifecycle.setBoolean(iris, active);
return iris;
}
private static Plugin namedPlugin(String name) {
Plugin plugin = mock(Plugin.class);
when(plugin.getName()).thenReturn(name);
return plugin;
}
}
@@ -0,0 +1,61 @@
package art.arcane.iris.update;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.volmlib.util.update.BukkitUpdateService;
import org.bukkit.plugin.Plugin;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
public class IrisUpdateNotifierTest {
@Test
public void acceptedSettingsReconfigureChecksAndClosingRemovesTheListener() {
IrisSettings previous = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
Plugin plugin = mock(Plugin.class);
BukkitUpdateService service = mock(BukkitUpdateService.class);
try (MockedStatic<BukkitUpdateService> registration = mockStatic(BukkitUpdateService.class)) {
registration.when(() -> BukkitUpdateService.register(eq(plugin), any())).thenReturn(service);
IrisUpdateNotifier notifier = new IrisUpdateNotifier(plugin);
try {
ArgumentCaptor<BukkitUpdateService.Options> options = ArgumentCaptor.forClass(BukkitUpdateService.Options.class);
registration.verify(() -> BukkitUpdateService.register(eq(plugin), options.capture()));
assertEquals("VolmitSoftware", options.getValue().owner());
assertEquals("Iris", options.getValue().repository());
assertEquals("iris.update", options.getValue().permission());
assertTrue(options.getValue().enabled().getAsBoolean());
clearInvocations(service);
assertFalse(IrisSettings.applyHotloadSnapshot(
"{\"general\":{\"updateNotifications\":false}}", candidate -> false));
assertTrue(options.getValue().enabled().getAsBoolean());
verifyNoInteractions(service);
IrisSettings.installHotloadSnapshot("{\"general\":{\"updateNotifications\":false}}");
assertFalse(options.getValue().enabled().getAsBoolean());
verify(service).reconfigure();
IrisSettings.settings.getGeneral().setUpdateNotifications(true);
assertFalse(options.getValue().enabled().getAsBoolean());
} finally {
notifier.close();
}
verify(service).close();
clearInvocations(service);
IrisSettings.installHotloadSnapshot("{}");
verifyNoInteractions(service);
} finally {
IrisSettings.settings = previous;
}
}
}
@@ -0,0 +1,41 @@
package art.arcane.iris.world.tree;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.junit.Test;
import java.util.Collection;
import java.util.List;
import static org.junit.Assert.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class RoutedBlockBreakEventTest {
@Test
public void nativeLootUsesTheReservedToolCloneWithoutReadingPlayerInventory() {
Block block = mock(Block.class);
Player player = mock(Player.class);
ItemStack reservedTool = mock(ItemStack.class);
ItemStack capturedTool = mock(ItemStack.class);
ItemStack drop = mock(ItemStack.class);
Collection<ItemStack> expected = List.of(drop);
when(reservedTool.clone()).thenReturn(capturedTool);
when(block.getDrops(capturedTool)).thenReturn(expected);
RoutedBlockBreakEvent event = new RoutedBlockBreakEvent(new RoutedBlockBreakEvent.Options(
block, player, mock(FellingRun.class), mock(TreeFellerSVC.class), reservedTool));
reservedTool.setType(Material.AIR);
assertSame(expected, event.captureNativeDrops());
verify(reservedTool).clone();
verify(block).getDrops(capturedTool);
verify(block, never()).getDrops(reservedTool);
verifyNoInteractions(player);
}
}
+3 -1
View File
@@ -56,7 +56,7 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.0-26.2')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3-26.2'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5')) String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5'))
Closure<String> irisArtifactName = { String platform, String targetVersion -> Closure<String> irisArtifactName = { String platform, String targetVersion ->
@@ -313,6 +313,8 @@ tasks.named('shadowJar', ShadowJar).configure {
// The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod // 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. // loader has. Shipping it only adds dead classes that reference a missing package.
exclude('art/arcane/iris/platform/bukkit/**') exclude('art/arcane/iris/platform/bukkit/**')
exclude('art/arcane/iris/platform/bootstrap/SlimJar*.class')
exclude('art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries*.class')
duplicatesStrategy = DuplicatesStrategy.EXCLUDE duplicatesStrategy = DuplicatesStrategy.EXCLUDE
filesMatching('META-INF/services/**') { FileCopyDetails entry -> filesMatching('META-INF/services/**') { FileCopyDetails entry ->
entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE
+3 -1
View File
@@ -56,7 +56,7 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.0-26.2')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.3-26.2'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.3-66.0.8')) String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.3-66.0.8'))
Closure<String> loaderDisplayVersion = { String loaderVersion -> Closure<String> loaderDisplayVersion = { String loaderVersion ->
@@ -305,6 +305,8 @@ tasks.named('shadowJar', ShadowJar).configure {
// The Bukkit platform binding cannot load without org.bukkit on the classpath, which no mod // 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. // loader has. Shipping it only adds dead classes that reference a missing package.
exclude('art/arcane/iris/platform/bukkit/**') exclude('art/arcane/iris/platform/bukkit/**')
exclude('art/arcane/iris/platform/bootstrap/SlimJar*.class')
exclude('art/arcane/iris/platform/bootstrap/NativeRuntimeLibraries*.class')
duplicatesStrategy = DuplicatesStrategy.EXCLUDE duplicatesStrategy = DuplicatesStrategy.EXCLUDE
filesMatching('META-INF/services/**') { FileCopyDetails entry -> filesMatching('META-INF/services/**') { FileCopyDetails entry ->
entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE entry.duplicatesStrategy = DuplicatesStrategy.INCLUDE
@@ -195,8 +195,6 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
if (!nativeGenerator.represents(level)) { if (!nativeGenerator.represents(level)) {
throw new IllegalArgumentException("NativeWorld does not use Iris generator '" + dimensionKey + "'"); throw new IllegalArgumentException("NativeWorld does not use Iris generator '" + dimensionKey + "'");
} }
requireGlobalStructureGeneration(
NativeModdedServer.forWorld(level).generateStructures(), dimensionKey);
Engine activeEngine = engineIfBound(); Engine activeEngine = engineIfBound();
Engine replacement = ModdedWorldEngines.prepareReplacement( Engine replacement = ModdedWorldEngines.prepareReplacement(
level, level,
@@ -462,13 +460,6 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
// A recorded failure stands until repoint or unbind resets the binding; re-running the bind would repeat the // A recorded failure stands until repoint or unbind resets the binding; re-running the bind would repeat the
// history verification on every chunk call and fail the same way. // history verification on every chunk call and fail the same way.
engineBinding.throwIfFailed(dimensionKey); engineBinding.throwIfFailed(dimensionKey);
try {
requireGlobalStructureGeneration(
NativeModdedServer.forWorld(level).generateStructures(), dimensionKey);
} catch (RuntimeException error) {
recordBindFailure(level, error);
throw error;
}
// Cache the owning level so hot paths never scan the level map to find themselves. // Cache the owning level so hot paths never scan the level map to find themselves.
boundLevel = level; boundLevel = level;
Engine cached = engine; Engine cached = engine;
@@ -575,29 +566,6 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
} }
} }
static void requireGlobalStructureGeneration(boolean enabled, String dimensionKey) {
if (enabled) {
return;
}
String remedy = integratedEnvironment()
? "enable 'Generate Structures' for this world; Iris requires it, then deny families through "
+ "importedStructures.disabled or complete keys through importedStructures.disabledExact"
: "set generate-structures=true in server.properties, restart the server, "
+ "then deny families through importedStructures.disabled or complete keys through "
+ "importedStructures.disabledExact";
throw new IllegalStateException("Iris generator '" + dimensionKey
+ "' cannot bind while generate-structures=false; " + remedy);
}
private static boolean integratedEnvironment() {
try {
return ModdedEngineBootstrap.loader().clientEnvironment();
} catch (Throwable e) {
// No loader bound (unit tests, very early boot): assume dedicated wording.
return false;
}
}
/** /**
* Null only while no level uses this generator (world creation screens, codec validation). A bound level whose * Null only while no level uses this generator (world creation screens, codec validation). A bound level whose
* engine is refused, unloading or stopping throws, so no stage can mistake it for an unbound generator. * engine is refused, unloading or stopping throws, so no stage can mistake it for an unbound generator.
@@ -325,7 +325,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
private H getNoiseBiome(Engine engine, int quartX, int quartY, int quartZ, private H getNoiseBiome(Engine engine, int quartX, int quartY, int quartZ,
S sampler) { S sampler) {
if (isGuaranteedSurfaceBiome(quartY, engine.getMinHeight())) { if (isGuaranteedSurfaceBiome(engine, quartY)) {
return getSurfaceStructureBiome(engine, quartX, quartZ, sampler); return getSurfaceStructureBiome(engine, quartX, quartZ, sampler);
} }
boolean cacheable = isBiomeCacheable( boolean cacheable = isBiomeCacheable(
@@ -512,7 +512,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
} }
boolean monumentQuery = isMonumentSurfaceBiomeQuery( boolean monumentQuery = isMonumentSurfaceBiomeQuery(
y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight()); y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight());
surfaceQuery = monumentQuery || isGuaranteedSurfaceBiome(minQuartY, engine.getMinHeight()); surfaceQuery = monumentQuery || isGuaranteedSurfaceBiome(engine, minQuartY);
} }
if (!surfaceQuery) { if (!surfaceQuery) {
return query.fallback().get(); return query.fallback().get();
@@ -701,6 +701,11 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
int blockZ = quartZ << 2; int blockZ = quartZ << 2;
int internalY = blockY - engine.getMinHeight(); int internalY = blockY - engine.getMinHeight();
int caveSwitchY = Math.max(-8 - engine.getMinHeight(), 40); int caveSwitchY = Math.max(-8 - engine.getMinHeight(), 40);
IrisBiome featureBiome = engine.getSubterrainBiome(blockX, internalY, blockZ);
if (featureBiome != null) {
return createBiomeResolution(engine, featureBiome, true, engine.getDimension(), engine.getData(),
blockX, blockY, blockZ);
}
DimensionStackLayout.Layer stackLayer = includeDimensionStack DimensionStackLayout.Layer stackLayer = includeDimensionStack
? stackLayout == null ? stackLayout == null
? resolveDimensionStackLayer(engine, blockX, internalY, blockZ) ? resolveDimensionStackLayer(engine, blockX, internalY, blockZ)
@@ -846,7 +851,13 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
return configuredBiomeKeys; return configuredBiomeKeys;
} }
static boolean isGuaranteedSurfaceBiome(int quartY, int minHeight) { static boolean isGuaranteedSurfaceBiome(Engine engine, int quartY) {
IrisDimension dimension = engine.getDimension();
if (dimension != null && dimension.allowsSubterrainFeatures()
&& !dimension.getSubterrainFeatures().isEmpty()) {
return false;
}
int minHeight = engine.getMinHeight();
int internalY = (quartY << 2) - minHeight; int internalY = (quartY << 2) - minHeight;
int caveSwitchY = Math.max(-8 - minHeight, 40); int caveSwitchY = Math.max(-8 - minHeight, 40);
return internalY > caveSwitchY; return internalY > caveSwitchY;
@@ -31,6 +31,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockDropHooks;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.structure.placement.LootResolver; import art.arcane.iris.structure.placement.LootResolver;
import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial; import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
@@ -181,7 +182,10 @@ public final class ModdedBlockBreakHandler {
NativeBlockState brokenState, NativeBlockState brokenState,
Engine engine Engine engine
) { ) {
KList<IrisBlockDrops> providers = providers(engine, position, brokenState); BiomeEnvironment environment = engine.getBiomeEnvironment(position.x(),
position.y() - engine.getMinHeight(), position.z());
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) {
KList<IrisBlockDrops> providers = providers(environment, brokenState);
if (providers.isEmpty()) { if (providers.isEmpty()) {
return Result.empty(); return Result.empty();
} }
@@ -202,6 +206,7 @@ public final class ModdedBlockBreakHandler {
} }
return new Result(drops, replaceVanillaDrops, null); return new Result(drops, replaceVanillaDrops, null);
} }
}
private static void removeMarker(Engine engine, NativeBlockPoint position) { private static void removeMarker(Engine engine, NativeBlockPoint position) {
int mantleY = position.y() - engine.getMinHeight(); int mantleY = position.y() - engine.getMinHeight();
@@ -225,22 +230,17 @@ public final class ModdedBlockBreakHandler {
engine.getMantle().getMantle().remove(position.x(), mantleY, position.z(), TreeBlockMaterial.class); engine.getMantle().getMantle().remove(position.x(), mantleY, position.z(), TreeBlockMaterial.class);
} }
private static KList<IrisBlockDrops> providers(Engine engine, NativeBlockPoint position, NativeBlockState brokenState) { private static KList<IrisBlockDrops> providers(BiomeEnvironment environment, NativeBlockState brokenState) {
KList<IrisBlockDrops> providers = new KList<>(); KList<IrisBlockDrops> providers = new KList<>();
IrisData data = engine.getData(); IrisData data = environment.data();
int relativeY = position.y() - engine.getMinHeight(); IrisBiome biome = environment.biome();
IrisBiome biome = engine.getBiome(position.x(), relativeY, position.z());
if (biome != null) {
addMatching(providers, biome.getBlockDrops(), brokenState, data); addMatching(providers, biome.getBlockDrops(), brokenState, data);
}
if (skipsParents(providers)) { if (skipsParents(providers)) {
return providers; return providers;
} }
IrisRegion region = engine.getRegion(position.x(), relativeY, position.z()); IrisRegion region = environment.region();
if (region != null) {
addMatching(providers, region.getBlockDrops(), brokenState, data); addMatching(providers, region.getBlockDrops(), brokenState, data);
} addMatching(providers, environment.dimension().getBlockDrops(), brokenState, data);
addMatching(providers, engine.getDimension().getBlockDrops(), brokenState, data);
return providers; return providers;
} }
@@ -50,7 +50,7 @@ public final class ModdedDecoratorHooks implements DecoratorPlatformHooks.FaceFi
int xx = rX + dx; int xx = rX + dx;
int yy = y + dy; int yy = y + dy;
int zz = rZ + dz; int zz = rZ + dz;
return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy > hunk.getHeight() return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy >= hunk.getHeight()
? null : hunk.get(xx, yy, zz); ? null : hunk.get(xx, yy, zz);
} }
} }
@@ -70,6 +70,7 @@ import art.arcane.iris.modded.service.ModdedEngineMaintenanceService;
import art.arcane.iris.modded.service.ModdedEntitySpawnService; import art.arcane.iris.modded.service.ModdedEntitySpawnService;
import art.arcane.iris.modded.service.ModdedLogFilterService; import art.arcane.iris.modded.service.ModdedLogFilterService;
import art.arcane.iris.modded.service.ModdedPreservationService; import art.arcane.iris.modded.service.ModdedPreservationService;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import art.arcane.iris.modded.service.ModdedSettingsHotloadService; import art.arcane.iris.modded.service.ModdedSettingsHotloadService;
import art.arcane.iris.modded.service.ModdedStudioHotloadService; import art.arcane.iris.modded.service.ModdedStudioHotloadService;
import art.arcane.iris.modded.service.ModdedTreeFellerService; import art.arcane.iris.modded.service.ModdedTreeFellerService;
@@ -477,6 +478,7 @@ public final class ModdedEngineBootstrap {
createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService()); createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService());
createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService()); createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService());
createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService()); createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService());
createdServices.register(ModdedPackUpdateService.class, new ModdedPackUpdateService());
ModdedStudioHotloadService studioHotloadService = createdServices.register( ModdedStudioHotloadService studioHotloadService = createdServices.register(
ModdedStudioHotloadService.class, new ModdedStudioHotloadService()); ModdedStudioHotloadService.class, new ModdedStudioHotloadService());
createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService()); createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService());
@@ -27,7 +27,6 @@ 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 * Owns the Iris generation pool and the single decision of whether this loader already runs chunk
@@ -43,7 +42,7 @@ public final class ModdedGenPool {
private static final long SHUTDOWN_DRAIN_MILLIS = 2_000L; private static final long SHUTDOWN_DRAIN_MILLIS = 2_000L;
private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger(); private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger();
private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem(); private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem();
private static final AtomicReference<ExecutorService> GEN_POOL = new AtomicReference<>(createGenPool()); private static ExecutorService generationPool = createGenPool();
private ModdedGenPool() { private ModdedGenPool() {
} }
@@ -63,39 +62,32 @@ public final class ModdedGenPool {
return CHUNK_SYSTEM.description(); return CHUNK_SYSTEM.description();
} }
static ExecutorService pool() { static synchronized ExecutorService pool() {
ExecutorService pool = GEN_POOL.get(); if (generationPool.isShutdown()) {
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"); throw new RejectedExecutionException("Iris gen pool is shut down");
} }
return restarted; return generationPool;
} }
static void start() { static synchronized void start() {
while (true) { if (!generationPool.isShutdown()) {
ExecutorService current = GEN_POOL.get();
if (current != null && !current.isShutdown()) {
return; return;
} }
ExecutorService created = createGenPool(); if (!generationPool.isTerminated()) {
if (GEN_POOL.compareAndSet(current, created)) { throw new IllegalStateException("Iris gen pool cannot restart while prior workers are still active");
return;
}
created.shutdownNow();
} }
generationPool = createGenPool();
} }
static void shutdown() { static void shutdown() {
ExecutorService pool = GEN_POOL.getAndSet(null); ExecutorService pool;
if (pool == null) { synchronized (ModdedGenPool.class) {
pool = generationPool;
if (pool.isTerminated()) {
return; return;
} }
pool.shutdown(); pool.shutdown();
}
try { try {
if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) { if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) {
return; return;
@@ -6,10 +6,9 @@ import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.BrokenPackException; import art.arcane.iris.pack.BrokenPackException;
import art.arcane.iris.pack.PackValidationCache; import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.world.history.GenerationEpoch; import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationEpochContractFactory; import art.arcane.iris.world.history.GenerationEpochContractFactory;
import art.arcane.iris.world.history.GenerationHistory; import art.arcane.iris.world.history.GenerationHistory;
@@ -174,7 +173,7 @@ final class ModdedGenerationHistoryStorage {
static ActivePack resolveActive(GenerationHistory history) throws IOException { static ActivePack resolveActive(GenerationHistory history) throws IOException {
GenerationEpoch epoch = history.activeEpoch(); GenerationEpoch epoch = history.activeEpoch();
Path packRoot = normalize(history.activePackRoot()); Path packRoot = normalize(history.activePackRoot());
validatePack(packRoot, epoch.packFingerprint()); validatePack(packRoot);
validateDimension(packRoot, epoch); validateDimension(packRoot, epoch);
return new ActivePack( return new ActivePack(
packRoot, packRoot,
@@ -184,13 +183,16 @@ final class ModdedGenerationHistoryStorage {
); );
} }
private static void validatePack(Path packRoot, String fingerprint) { static PackValidationResult validatePack(Path packRoot) {
String contextFingerprint = PackValidationCache.contextFingerprint(); PackValidationResult validation = PackValidationRegistry.getMatching(
PackValidationResult validation = PackValidator.validate(packRoot.toFile()); packRoot, PackFingerprints.computePackTreeFingerprint(packRoot.toFile()));
PackValidationRegistry.publish(packRoot, validation, fingerprint, contextFingerprint); if (validation == null) {
validation = ModdedStartup.validatePack(packRoot.toFile());
}
if (!validation.isLoadable()) { if (!validation.isLoadable()) {
throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors()); throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors());
} }
return validation;
} }
private static void validateDimension( private static void validateDimension(
@@ -20,13 +20,11 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.diagnostics.splash.IrisSplashComposer; import art.arcane.iris.diagnostics.splash.IrisSplashComposer;
import art.arcane.iris.diagnostics.splash.IrisSplashRenderer; import art.arcane.iris.diagnostics.splash.IrisSplashRenderer;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import java.io.File;
public final class ModdedIrisSplash { public final class ModdedIrisSplash {
@@ -34,18 +32,11 @@ public final class ModdedIrisSplash {
} }
public static void print(NativeModdedLoader loader) { public static void print(NativeModdedLoader loader) {
printPacks(loader);
if (isLogoEnabled()) { if (isLogoEnabled()) {
printLogo(loader); printLogo(loader);
} }
} }
private static void printPacks(NativeModdedLoader loader) {
File packFolder = IrisPlatforms.get().packsFolderNoCreate();
IrisSplashComposer.composePackLines(packFolder, IrisLogging::reportError)
.thenAccept(lines -> lines.forEach(IrisLogging::info));
}
private static void printLogo(NativeModdedLoader loader) { private static void printLogo(NativeModdedLoader loader) {
String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion(); String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion();
String[] splash = IrisSplashRenderer.renderPlain(); String[] splash = IrisSplashRenderer.renderPlain();
@@ -45,6 +45,9 @@ public final class ModdedMixinAudit {
private static final AtomicBoolean AUDITED = new AtomicBoolean(false); private static final AtomicBoolean AUDITED = new AtomicBoolean(false);
static final List<ExpectedMixin> EXPECTED = List.of( static final List<ExpectedMixin> EXPECTED = List.of(
new ExpectedMixin(ENTITY_CONFIG, "RegistryClientNamesMixin",
NativeMixinTarget.REGISTRY_PACKET, "volmlib$clientRegistryNames",
false, NativeMixinFlags::registryClientNamesRan),
new ExpectedMixin(ENTITY_CONFIG, "EntityPersistenceMixin", new ExpectedMixin(ENTITY_CONFIG, "EntityPersistenceMixin",
NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence", NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence",
false, NativeMixinFlags::entityPersistenceRan), false, NativeMixinFlags::entityPersistenceRan),
@@ -108,7 +111,7 @@ public final class ModdedMixinAudit {
ModdedIrisLog.error(" missing: {}", entry); ModdedIrisLog.error(" missing: {}", entry);
} }
ModdedIrisLog.error("The mixin config was not registered for this loader (fabric.mod.json mixins, neoforge.mods.toml [[mixins]], forge MixinConfigs manifest attribute)."); ModdedIrisLog.error("The mixin config was not registered for this loader (fabric.mod.json mixins, neoforge.mods.toml [[mixins]], forge MixinConfigs manifest attribute).");
ModdedIrisLog.error("Entity persistence, custom mob loot, parallel structure safety, or Iris world-type labels are disabled until this is fixed."); ModdedIrisLog.error("Entity persistence, custom mob loot, parallel structure safety, readable biome identifiers, or Iris world-type labels are disabled until this is fixed.");
ModdedIrisLog.error("==============================================================="); ModdedIrisLog.error("===============================================================");
} }
@@ -122,17 +122,6 @@ final class ModdedNativeStructurePolicy implements NativeModdedStructureStage.Po
return ((IrisMaterialPalette) source).get(rng, x, y, z, generator.engine().getData()); return ((IrisMaterialPalette) source).get(rng, x, y, z, generator.engine().getData());
} }
@Override
public RuntimeException structuresDisabled(int chunkX, int chunkZ) {
return new IllegalStateException("Iris cannot generate native structures in chunk "
+ chunkX + "," + chunkZ
+ " because generate-structures=false disables them outside the pack. That flag is fixed when "
+ "the world is created (server.properties generate-structures, or the Generate Structures "
+ "toggle in singleplayer), so it cannot be changed for this world: create a new world with "
+ "structures enabled, then deny families through importedStructures.disabled or complete keys "
+ "through importedStructures.disabledExact");
}
@Override @Override
public String generationLabel(String structureId) { public String generationLabel(String structureId) {
return "Iris native structure " + structureId; return "Iris native structure " + structureId;
@@ -27,16 +27,15 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.UUID; 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.ConcurrentMap;
public final class ModdedPrimaryWorldRouter { public final class ModdedPrimaryWorldRouter {
private static final int TICK_INTERVAL = 20; private static final int TICK_INTERVAL = 20;
private static final Set<UUID> routed = ConcurrentHashMap.newKeySet(); private static final ConcurrentMap<UUID, CompletableFuture<Boolean>> routes = new ConcurrentHashMap<>();
private static final Set<UUID> inFlight = ConcurrentHashMap.newKeySet();
private static int tickCounter = 0; private static int tickCounter = 0;
private static volatile String loginRefusal; private static volatile String loginRefusal;
private static volatile String absentPrimary; private static volatile String absentPrimary;
@@ -45,8 +44,12 @@ public final class ModdedPrimaryWorldRouter {
} }
public static void clear() { public static void clear() {
routed.clear(); routes.forEach((id, route) -> {
inFlight.clear(); if (routes.remove(id, route)) {
route.cancel(false);
}
});
tickCounter = 0;
loginRefusal = null; loginRefusal = null;
absentPrimary = null; absentPrimary = null;
} }
@@ -57,8 +60,10 @@ public final class ModdedPrimaryWorldRouter {
*/ */
public static void forget(UUID player) { public static void forget(UUID player) {
if (player != null) { if (player != null) {
routed.remove(player); CompletableFuture<Boolean> route = routes.remove(player);
inFlight.remove(player); if (route != null) {
route.cancel(false);
}
} }
} }
@@ -110,36 +115,52 @@ public final class ModdedPrimaryWorldRouter {
return; return;
} }
server.forEachPlayer(player -> { server.forEachPlayer(player -> routePlayer(server, player, overworld, target, primary));
}
private static void routePlayer(NativeModdedServer server, NativeProtocolPlayer player,
NativeWorld overworld, NativeWorld target, String primary) {
UUID id = player.id(); UUID id = player.id();
if (routed.contains(id) || !inFlight.add(id)) { if (routes.containsKey(id)) {
return; return;
} }
CompletableFuture<Boolean> route = new CompletableFuture<>();
if (routes.putIfAbsent(id, route) != null) {
return;
}
route.whenComplete((success, failure) -> {
if (!Boolean.TRUE.equals(success)) {
routes.remove(id, route);
}
});
if (!player.isInWorld(overworld)) { if (!player.isInWorld(overworld)) {
inFlight.remove(id); route.complete(true);
routed.add(id);
return; return;
} }
try { try {
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player, CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player,
new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(), new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(),
ModdedTeleportDeadline.fromNow())); ModdedTeleportDeadline.fromNow()));
teleport.whenComplete((success, failure) -> { route.whenComplete((success, failure) -> {
inFlight.remove(id); if (!Boolean.TRUE.equals(success) && !teleport.isDone()) {
if (Boolean.TRUE.equals(success) && failure == null) { teleport.cancel(false);
routed.add(id);
return;
} }
});
teleport.whenComplete((success, failure) -> {
if (failure != null) { if (failure != null) {
if (route.completeExceptionally(failure)) {
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'",
id, primary, failure); id, primary, failure);
} }
}); } else {
} catch (Throwable e) { route.complete(Boolean.TRUE.equals(success));
inFlight.remove(id);
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, e);
} }
}); });
} catch (Throwable failure) {
if (route.completeExceptionally(failure)) {
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, failure);
}
}
} }
/** /**
@@ -31,6 +31,7 @@ import art.arcane.iris.platform.protocol.IrisVisionRequestService;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.protocol.IrisProtocol; import art.arcane.iris.spi.protocol.IrisProtocol;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
@@ -128,6 +129,10 @@ public final class ModdedProtocolHandler {
} }
String sessionId = sessionId(player); String sessionId = sessionId(player);
ModdedStartup.warnStartupFailuresTo(player); ModdedStartup.warnStartupFailuresTo(player);
ModdedPackUpdateService packUpdates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
if (packUpdates != null) {
packUpdates.notifyPlayer(player);
}
IrisSessionRegistry current = registry; IrisSessionRegistry current = registry;
ModdedProtocolTransport currentTransport = transport; ModdedProtocolTransport currentTransport = transport;
if (current == null || currentTransport == null) { if (current == null || currentTransport == null) {
@@ -18,10 +18,13 @@
package art.arcane.iris.modded; package art.arcane.iris.modded;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.pack.BrokenPackException; import art.arcane.iris.pack.BrokenPackException;
import art.arcane.iris.pack.PackDirectoryResolver; import art.arcane.iris.pack.PackDirectoryResolver;
import art.arcane.iris.pack.PackDownloader; import art.arcane.iris.pack.PackDownloader;
import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator; import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.command.ModdedPackCommands; import art.arcane.iris.modded.command.ModdedPackCommands;
@@ -31,15 +34,11 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
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.Map;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
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 int COMPAT_BOOT_KEY_CAP = 3; private static final int COMPAT_BOOT_KEY_CAP = 3;
@@ -60,36 +59,9 @@ public final class ModdedStartup {
if (!PREPARED.compareAndSet(false, true)) { if (!PREPARED.compareAndSet(false, true)) {
return; return;
} }
reportLegacyPacksDirectory();
validateAllPacks(); validateAllPacks();
} }
/**
* Older builds mkdir'd (and stale guidance sometimes populated) config/iris/packs, but modded
* packs live under config/irisworldgen/packs. Never auto-move user content: warn loudly when
* the legacy directory holds packs, and quietly remove it when it is empty.
*/
private static void reportLegacyPacksDirectory() {
try {
File legacy = ModdedEngineBootstrap.loader().configDir().resolve("iris").resolve("packs").toFile();
if (!legacy.isDirectory()) {
return;
}
if (!PackDirectoryResolver.listVisiblePackDirectories(legacy).isEmpty()) {
File real = art.arcane.iris.spi.IrisPlatforms.get().packsFolderNoCreate();
ModdedIrisLog.warn("Iris found packs under the legacy directory {} - modded packs load from {} only. Move them there.",
legacy.getAbsolutePath(), real.getAbsolutePath());
return;
}
String[] entries = legacy.list();
if (entries == null || entries.length == 0) {
legacy.delete();
}
} catch (Throwable e) {
ModdedIrisLog.debug("Iris legacy packs directory check failed", e);
}
}
/** /**
* Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler: * 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 * ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this
@@ -140,8 +112,7 @@ public final class ModdedStartup {
} }
for (File packDir : packDirs) { for (File packDir : packDirs) {
try { try {
PackValidationResult result = PackValidator.validate(packDir); PackValidationResult result = validatePack(packDir);
PackValidationRegistry.publish(result);
String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null; String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null;
String compatSummary = PackValidator.compatSummary(result, minecraftVersion); String compatSummary = PackValidator.compatSummary(result, minecraftVersion);
String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary; String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary;
@@ -184,16 +155,15 @@ 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); Path packRoot = packDir.toPath();
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 cached = PackValidationRegistry.getMatching(
PackValidationRegistry.publish(result); packRoot, PackFingerprints.computePackTreeFingerprint(packDir));
return PackValidationRegistry.requireLoadable(pack); if (cached != null) {
return PackValidationRegistry.requireLoadable(packRoot);
}
validatePack(packDir);
return PackValidationRegistry.requireLoadable(packRoot);
} catch (BrokenPackException e) { } catch (BrokenPackException e) {
throw e; throw e;
} catch (Throwable e) { } catch (Throwable e) {
@@ -212,6 +182,42 @@ public final class ModdedStartup {
} }
} }
public static PackValidationResult validatePack(File packDir) {
Path packRoot = packDir.toPath();
PackValidationRegistry.ValidationTicket ticket = PackValidationRegistry.tryBeginValidation(packRoot);
if (ticket == null) {
throw new BrokenPackException(packDir.getName(), List.of("Pack content is currently being changed."));
}
String contentFingerprint = PackFingerprints.computePackTreeFingerprint(packDir);
String contextFingerprint = PackValidationCache.contextFingerprint();
PackValidationResult result;
try {
result = PackValidator.validate(packDir);
} catch (Throwable failure) {
ModdedIrisLog.error("Iris pack validation failed for '{}'", packDir.getName(), failure);
String detail = failure.getMessage();
if (detail == null || detail.isBlank()) {
detail = failure.getClass().getSimpleName();
}
result = new PackValidationResult(
packDir.getName(),
List.of("Pack validation failed with " + failure.getClass().getSimpleName() + ": " + detail),
List.of(),
System.currentTimeMillis());
}
if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packDir))
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
PackValidationRegistry.remove(packRoot);
PackValidationRegistry.remove(packDir.getName());
throw new BrokenPackException(packDir.getName(), List.of("Pack content or validation context changed during validation."));
}
if (!PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) {
throw new BrokenPackException(packDir.getName(), List.of("Pack content changed during validation."));
}
PackValidationRegistry.publish(result);
return result;
}
static void reinjectPersistentDimensions(NativeModdedServer server) { static void reinjectPersistentDimensions(NativeModdedServer server) {
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions = List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
ModdedDimensionRegistryStore.loadForStartup(server); ModdedDimensionRegistryStore.loadForStartup(server);
@@ -266,23 +272,6 @@ public final class ModdedStartup {
+ GenerationRefusalNotice.summary(failure)); + GenerationRefusalNotice.summary(failure));
} }
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) {
ModdedIrisLog.debug("Iris could not stat {} for validation reuse; revalidating", root, unreadable);
return Long.MAX_VALUE;
}
return newest;
}
/** /**
* True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime * True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime
* dimension that is not loaded yet is still on its way. * dimension that is not loaded yet is still on its way.
@@ -296,7 +285,7 @@ public final class ModdedStartup {
* the console. Tell the operators who can actually act on it when they join. * the console. Tell the operators who can actually act on it when they join.
*/ */
public static void warnStartupFailuresTo(NativeProtocolPlayer player) { public static void warnStartupFailuresTo(NativeProtocolPlayer player) {
if (player == null || !player.isGameMaster()) { if (player == null || (!player.isGameMaster() && !player.isServerOwner())) {
return; return;
} }
for (String failure : DIMENSION_FAILURES) { for (String failure : DIMENSION_FAILURES) {
@@ -44,6 +44,8 @@ import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.iris.pack.value.IrisRange; import art.arcane.iris.pack.value.IrisRange;
import art.arcane.iris.world.entity.IrisSpawnGroup; import art.arcane.iris.world.entity.IrisSpawnGroup;
import art.arcane.iris.world.entity.IrisSpawner; import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.world.entity.EntitySpawnSeed;
import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext;
import art.arcane.iris.generation.decoration.IrisSurface; import art.arcane.iris.generation.decoration.IrisSurface;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
@@ -56,11 +58,13 @@ import art.arcane.volmlib.util.matter.MatterMarker;
import art.arcane.volmlib.util.matter.slices.MarkerMatter; import art.arcane.volmlib.util.matter.slices.MarkerMatter;
import java.util.Map; import java.util.Map;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.Comparator;
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue; import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -235,6 +239,10 @@ public final class ModdedWorldManager implements EngineWorldManager {
} }
boolean initialSpawnChunk(NativeWorld level, int chunkX, int chunkZ) { boolean initialSpawnChunk(NativeWorld level, int chunkX, int chunkZ) {
if (!isEntitySpawningEnabledForCurrentWorld()
|| !NativeSpawnQueries.ambientAllowed(level, chunkX, chunkZ, true)) {
return false;
}
if (!ModdedEntitySpawner.chunksSafe(entityRuntime(level), chunkX, chunkZ)) { if (!ModdedEntitySpawner.chunksSafe(entityRuntime(level), chunkX, chunkZ)) {
return false; return false;
} }
@@ -249,54 +257,25 @@ public final class ModdedWorldManager implements EngineWorldManager {
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ); MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try { try {
List<PreparedMarkerSpawn> markers = markerSystemEnabled() List<PreparedMarkerSpawn> markers = markerSystemEnabled()
? prepareMarkerSpawns(level, chunkX, chunkZ, chunk) : List.of(); ? prepareMarkerSpawns(level, chunkX, chunkZ, chunk, true) : List.of();
Optional<BiomeEnvironment> environment = ambientSystemEnabled() Optional<BiomeEnvironment> environment = ambientSystemEnabled()
? resolveSurfaceEnvironment(chunkX, chunkZ) : Optional.empty(); ? resolveSurfaceEnvironment(chunkX, chunkZ) : Optional.empty();
chunk.raiseFlagUnchecked(INITIAL_SPAWN_COMPLETION_FLAG, () -> { if (ambientSystemEnabled() && engine.getComplex() == null) {
return false;
}
spawnPreparedMarkers(level, markers, true); spawnPreparedMarkers(level, markers, true);
if (environment.isPresent()) { if (environment.isPresent()) {
scheduleInitialFollowUp(level, chunkX, chunkZ, environment.get()); try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment.get())) {
spawnAmbient(level, chunkX, chunkZ, true, environment.get());
} }
}); }
chunk.raiseFlagUnchecked(INITIAL_SPAWN_COMPLETION_FLAG, () -> {});
} finally { } finally {
chunk.release(); chunk.release();
} }
return true; return true;
} }
private void scheduleInitialFollowUp(NativeWorld level, int chunkX, int chunkZ, BiomeEnvironment environment) {
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
if (scheduler == null) {
IrisLogging.error("Iris could not schedule the initial entity-spawn follow-up because the modded scheduler is unavailable.");
return;
}
scheduler.laterGlobal(
() -> EngineLifecycleTasks.run(
engine,
"modded_world_manager_initial_spawn_followup",
() -> runInitialFollowUp(level, chunkX, chunkZ, environment)),
RNG.r.i(5, 200));
}
private void runInitialFollowUp(NativeWorld level, int chunkX, int chunkZ, BiomeEnvironment environment) {
Mantle<Matter> mantle = engine.getMantle().getMantle();
if (closed || engine.isClosed() || mantle.isClosed() || !isEntitySpawningEnabledForCurrentWorld()) {
return;
}
if (!level.isChunkLoaded(chunkX, chunkZ) || !mantle.isChunkLoaded(chunkX, chunkZ)) {
return;
}
if (engine.getComplex() == null) {
return;
}
if (ambientSystemEnabled()) {
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) {
spawnAmbient(level, chunkX, chunkZ, true, environment);
}
}
}
private void warmupMantleChunkAsync(long key, int chunkX, int chunkZ) { private void warmupMantleChunkAsync(long key, int chunkX, int chunkZ) {
if (closed || !mantleWarmups.add(key)) { if (closed || !mantleWarmups.add(key)) {
return; return;
@@ -380,11 +359,14 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (!mantle.isChunkLoaded(chunkX, chunkZ)) { if (!mantle.isChunkLoaded(chunkX, chunkZ)) {
return; return;
} }
if (!mantle.hasFlag(chunkX, chunkZ, INITIAL_SPAWN_COMPLETION_FLAG)) {
return;
}
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ); MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try { try {
if (markerSystemEnabled()) { if (markerSystemEnabled()) {
spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk), false); spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk, false), false);
} }
if (ambientSystemEnabled()) { if (ambientSystemEnabled()) {
spawnAmbient(level, chunkX, chunkZ, false); spawnAmbient(level, chunkX, chunkZ, false);
@@ -394,7 +376,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
} }
} }
private List<PreparedMarkerSpawn> prepareMarkerSpawns(NativeWorld level, int chunkX, int chunkZ, MantleChunk<Matter> chunk) { private List<PreparedMarkerSpawn> prepareMarkerSpawns(NativeWorld level, int chunkX, int chunkZ, MantleChunk<Matter> chunk, boolean initial) {
int minHeight = engine.getWorld().minHeight(); int minHeight = engine.getWorld().minHeight();
KList<IrisPosition> obstructed = new KList<>(); KList<IrisPosition> obstructed = new KList<>();
List<PreparedMarkerSpawn> prepared = new ArrayList<>(); List<PreparedMarkerSpawn> prepared = new ArrayList<>();
@@ -429,62 +411,71 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (spawners.isEmpty()) { if (spawners.isEmpty()) {
return; return;
} }
IrisSpawner chosen = spawners.getRandom(); if (initial) {
spawners.sort(Comparator.comparing(IrisSpawner::getLoadKey, Comparator.nullsFirst(Comparator.naturalOrder())));
}
RNG markerRng = initial ? EntitySpawnSeed.marker(engine.getSeedManager().getEntity(), worldX, worldY, worldZ)
: new RNG(RNG.r.nextLong());
IrisSpawner chosen = spawners.getRandom(markerRng);
if (chosen == null) { if (chosen == null) {
return; return;
} }
prepared.add(new PreparedMarkerSpawn(new IrisPosition(worldX, worldY, worldZ), chosen, environment)); prepared.add(new PreparedMarkerSpawn(new IrisPosition(worldX, worldY, worldZ), chosen, resolved, markerRng, environment));
}); });
Mantle<Matter> mantle = engine.getMantle().getMantle(); Mantle<Matter> mantle = engine.getMantle().getMantle();
for (IrisPosition position : obstructed) { for (IrisPosition position : obstructed) {
mantle.remove(position.getX(), position.getY(), position.getZ(), MatterMarker.class); mantle.remove(position.getX(), position.getY(), position.getZ(), MatterMarker.class);
} }
if (initial) {
prepared.sort(Comparator.comparingInt((PreparedMarkerSpawn marker) -> marker.position().getX())
.thenComparingInt(marker -> marker.position().getY())
.thenComparingInt(marker -> marker.position().getZ()));
}
return List.copyOf(prepared); return List.copyOf(prepared);
} }
private KList<IrisSpawner> resolveMarkerSpawners(IrisMarker marker, BiomeEnvironment environment) { private KList<IrisSpawner> resolveMarkerSpawners(IrisMarker marker, BiomeEnvironment environment) {
KList<IrisSpawner> spawners = new KList<>(); KList<IrisSpawner> spawners = new KList<>();
Set<IrisSpawner> seen = new HashSet<>();
for (String key : marker.getSpawners()) { for (String key : marker.getSpawners()) {
IrisSpawner spawner = environment.data().getSpawnerLoader().load(key); IrisSpawner spawner = environment.data().getSpawnerLoader().load(key);
if (spawner == null) { if (spawner == null) {
IrisLogging.error("Cannot load spawner: " + key + " for marker on " + engine.getName()); IrisLogging.error("Cannot load spawner: " + key + " for marker on " + engine.getName());
continue; continue;
} }
spawner.setReferenceMarker(marker); if (!spawner.isCompatExcluded() && seen.add(spawner)) {
spawners.add(spawner); spawners.add(spawner);
} }
}
return spawners; return spawners;
} }
private void spawnPreparedMarkers(NativeWorld level, List<PreparedMarkerSpawn> markers, boolean initial) { private void spawnPreparedMarkers(NativeWorld level, List<PreparedMarkerSpawn> markers, boolean initial) {
for (PreparedMarkerSpawn marker : markers) { for (PreparedMarkerSpawn marker : markers) {
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(marker.environment())) { try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(marker.environment())) {
spawnFromSpawner(level, marker.position(), marker.spawner(), initial); spawnFromSpawner(level, marker.position(), marker.rng(), new SpawnContext(marker.spawner(), marker.marker(), initial));
} }
} }
} }
private void spawnFromSpawner(NativeWorld level, IrisPosition position, IrisSpawner spawner, boolean initial) { private void spawnFromSpawner(NativeWorld level, IrisPosition position, RNG rng, SpawnContext context) {
KList<IrisEntitySpawn> spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns(); IrisSpawner spawner = context.spawner();
KList<IrisEntitySpawn> spawns = context.initial() ? spawner.getInitialSpawns() : spawner.getSpawns();
if (spawns.isEmpty()) { if (spawns.isEmpty()) {
return; return;
} }
for (IrisEntitySpawn entry : spawns) { IrisEntitySpawn chosen = rarityPick(spawns, rng);
entry.setReferenceSpawner(spawner);
entry.setReferenceMarker(spawner.getReferenceMarker());
}
IrisEntitySpawn chosen = rarityPick(spawns);
if (chosen == null) { if (chosen == null) {
return; return;
} }
int chunkX = position.getX() >> 4; int chunkX = position.getX() >> 4;
int chunkZ = position.getZ() >> 4; int chunkZ = position.getZ() >> 4;
if (!canSpawn(spawner, chunkX, chunkZ)) { if (!canSpawn(spawner, chunkX, chunkZ, context.initial())) {
return; return;
} }
int spawned = spawnEntryAt(level, chosen, spawner, position); int spawned = spawnEntryAt(level, chosen, position, rng, context);
if (spawned > 0) { if (spawned > 0 && !context.initial()) {
spawner.spawn(engine, chunkX, chunkZ); spawner.spawn(engine, chunkX, chunkZ);
} }
} }
@@ -521,59 +512,168 @@ public final class ModdedWorldManager implements EngineWorldManager {
} }
IrisBiome biome = environment.biome(); IrisBiome biome = environment.biome();
Map<String, Integer> chunkMobs = NativeSpawnQueries.livingEntityCategories(level, chunkX, chunkZ); Map<String, Integer> chunkMobs = new HashMap<>(initial ? Map.of()
: NativeSpawnQueries.livingEntityCategories(level, chunkX, chunkZ));
RNG rng = initial ? EntitySpawnSeed.chunk(engine.getSeedManager().getEntity(), chunkX, chunkZ)
: new RNG(RNG.r.nextLong());
KList<IrisEntitySpawn> pool = new KList<>(); KList<SpawnCandidate> pool = new KList<>();
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial, false);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial, false);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial, false);
if (environment.dimension().hasUndergroundSpawners(engine)) {
spawnUnderground(level, chunkX, chunkZ, initial, chunkMobs, rng.nextParallelRNG(0x5A17D3B48269C0EFL));
}
if (pool.isEmpty()) { if (pool.isEmpty()) {
return; return;
} }
IrisEntitySpawn chosen = rarityPick(pool); SpawnCandidate chosen = rarityPick(pool, rng);
if (chosen == null || chosen.getReferenceSpawner() == null) { if (chosen == null) {
return; return;
} }
IrisSpawner spawner = chosen.getReferenceSpawner(); IrisSpawner spawner = chosen.spawner();
if (!canSpawn(spawner, chunkX, chunkZ)) { if (!canSpawn(spawner, chunkX, chunkZ, initial)) {
return; return;
} }
int spawned = spawnEntry(level, chosen, spawner, chunkX, chunkZ, int spawned = spawnEntry(level, chosen.entry(), chunkX, chunkZ,
remainingCapacity(chosen, spawner, chunkMobs)); remainingCapacity(chosen.entry(), spawner, chunkMobs), rng, new SpawnContext(spawner, null, initial));
if (spawned > 0) { recordSpawned(chosen.entry(), chunkMobs, spawned);
if (spawned > 0 && !initial) {
spawner.spawn(engine, chunkX, chunkZ); spawner.spawn(engine, chunkX, chunkZ);
} }
} }
private void collectSpawns(KList<IrisEntitySpawn> pool, KList<IrisSpawner> spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, Map<String, Integer> chunkMobs, boolean initial) { private void spawnUnderground(NativeWorld level, int chunkX, int chunkZ, boolean initial,
Map<String, Integer> chunkMobs, RNG random) {
for (IrisSurface surface : List.of(IrisSurface.LAND, IrisSurface.WATER, IrisSurface.LAVA)) {
IrisPosition candidate = findUndergroundCandidate(level, chunkX, chunkZ,
random.nextParallelRNG(surface.ordinal()), surface);
if (candidate == null) {
continue;
}
BiomeEnvironment environment;
try {
environment = engine.getBiomeOrMantleEnvironment(candidate.getX(),
candidate.getY() - engine.getWorld().minHeight(), candidate.getZ());
} catch (SavedBiomeUnavailableException unavailable) {
continue;
}
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) {
KList<SpawnCandidate> pool = new KList<>();
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()),
environment.biome(), chunkX, chunkZ, chunkMobs, initial, true);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()),
null, chunkX, chunkZ, chunkMobs, initial, true);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.biome().getEntitySpawners()),
null, chunkX, chunkZ, chunkMobs, initial, true);
pool.removeIf(selection -> !matchesUndergroundSurface(selection.entry(), surface));
SpawnCandidate selected = rarityPick(pool, random);
if (selected == null) {
continue;
}
int capacity = remainingCapacity(selected.entry(), selected.spawner(), chunkMobs);
int spawned = spawnUndergroundEntry(level, candidate, selected, capacity, random, initial);
recordSpawned(selected.entry(), chunkMobs, spawned);
if (spawned > 0 && !initial) {
selected.spawner().spawn(engine, chunkX, chunkZ);
}
return;
}
}
}
private boolean matchesUndergroundSurface(IrisEntitySpawn entry, IrisSurface surface) {
IrisEntity entity = entry.getRealEntity(engine);
return entity != null && entity.getSurface().isFluid() == surface.isFluid()
&& (!surface.isFluid() || entity.getSurface() == surface);
}
static IrisPosition findUndergroundCandidate(NativeWorld level, int chunkX, int chunkZ, RNG random, IrisSurface surface) {
int minimumY = level.minHeight() + 1;
for (int attempt = 0; attempt < 8; attempt++) {
int x = (chunkX << 4) + random.i(1, 15);
int z = (chunkZ << 4) + random.i(1, 15);
int maximumY = Math.min(level.maxHeight() - 3, NativeSpawnQueries.surfaceHeight(level, x, z, true) - 1);
if (maximumY < minimumY) {
continue;
}
int span = maximumY - minimumY + 1;
int startY = LootResolver.inclusive(random, minimumY, maximumY);
for (int sample = 0; sample < Math.min(span, 128); sample++) {
int y = minimumY + Math.floorMod(startY - minimumY - sample, span);
NativeBlockState state = level.getBlock(x, y, z);
boolean acceptable = surface.isFluid() ? matchesSurface(surface, state)
: state != null && state.isAir() && NativeSpawnQueries.solid(level.getBlock(x, y - 1, z))
&& level.getBlock(x, y + 1, z).isAir();
if (acceptable) {
return new IrisPosition(x, y, z);
}
}
}
return null;
}
private int spawnUndergroundEntry(NativeWorld level, IrisPosition candidate, SpawnCandidate selection,
int capacity, RNG random, boolean initial) {
IrisEntity entity = selection.entry().getRealEntity(engine);
if (entity == null) {
return 0;
}
int x = candidate.getX();
int y = candidate.getY();
int z = candidate.getZ();
int count = Math.min(Math.max(0, capacity), LootResolver.inclusive(random,
selection.entry().getMinSpawns(), selection.entry().getMaxSpawns()));
int spawned = 0;
for (int ordinal = 0; ordinal < count; ordinal++) {
if (!initial && !lightAllowed(selection.spawner(), level, x, y, z)
|| !surfaceMatches(entity.getSurface(), level, x, y, z)
|| !ModdedEntitySpawner.isAreaClearForSpawn(entityRuntime(level), entity, x, y, z)) {
continue;
}
if (ModdedEntitySpawner.spawn(engine, entity, entityRuntime(level), x, y, z,
EntitySpawnSeed.entity(random.getSeed(), ordinal)) != null) {
spawned++;
}
}
return spawned;
}
private void collectSpawns(KList<SpawnCandidate> pool, KList<IrisSpawner> spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, Map<String, Integer> chunkMobs, boolean initial, boolean underground) {
for (IrisSpawner spawner : spawners) { for (IrisSpawner spawner : spawners) {
if (spawner == null) { if (spawner == null || (spawner.getGroup() == IrisSpawnGroup.CAVE) != underground) {
continue; continue;
} }
if (biomeFilter != null && !spawner.isValid(biomeFilter)) { if (biomeFilter != null && !spawner.isValid(biomeFilter)) {
continue; continue;
} }
if (!canSpawn(spawner, chunkX, chunkZ)) { if (!canSpawn(spawner, chunkX, chunkZ, initial)) {
continue; continue;
} }
KList<IrisEntitySpawn> spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns(); KList<IrisEntitySpawn> spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns();
for (IrisEntitySpawn entry : spawns) { for (IrisEntitySpawn entry : spawns) {
entry.setReferenceSpawner(spawner);
entry.setReferenceMarker(spawner.getReferenceMarker());
if (remainingCapacity(entry, spawner, chunkMobs) > 0) { if (remainingCapacity(entry, spawner, chunkMobs) > 0) {
pool.add(entry); pool.add(new SpawnCandidate(entry, spawner));
} }
} }
} }
} }
private void recordSpawned(IrisEntitySpawn entry, Map<String, Integer> counts, int spawned) {
IrisEntity entity = entry.getRealEntity(engine);
if (spawned > 0 && entity != null) {
counts.merge(entity.spawnCategory(), spawned, Integer::sum);
}
}
private int remainingCapacity(IrisEntitySpawn entry, IrisSpawner spawner, Map<String, Integer> counts) { private int remainingCapacity(IrisEntitySpawn entry, IrisSpawner spawner, Map<String, Integer> counts) {
IrisEntity entity = entry.getRealEntity(engine); IrisEntity entity = entry.getRealEntity(engine);
return spawner.remainingCapacity(entity, counts); return spawner.remainingCapacity(entity, counts);
} }
private int spawnEntry(NativeWorld level, IrisEntitySpawn entry, IrisSpawner spawner, int chunkX, int chunkZ, int remainingCapacity) { private int spawnEntry(NativeWorld level, IrisEntitySpawn entry, int chunkX, int chunkZ,
int remainingCapacity, RNG rng, SpawnContext context) {
IrisEntity irisEntity = entry.getRealEntity(engine); IrisEntity irisEntity = entry.getRealEntity(engine);
if (irisEntity == null) { if (irisEntity == null) {
return 0; return 0;
@@ -581,18 +681,24 @@ public final class ModdedWorldManager implements EngineWorldManager {
int min = entry.getMinSpawns(); int min = entry.getMinSpawns();
int max = entry.getMaxSpawns(); int max = entry.getMaxSpawns();
int count = Math.min(Math.max(0, remainingCapacity), LootResolver.inclusive(RNG.r, min, max)); int count = Math.min(Math.max(0, remainingCapacity), LootResolver.inclusive(rng, min, max));
if (count <= 0) { if (count <= 0) {
return 0; return 0;
} }
RNG entityRng = entry.getRng().aquire(() -> new RNG(engine.getSeedManager().getEntity())); IrisSpawner spawner = context.spawner();
IrisSpawnGroup group = spawner.getGroup(); IrisSpawnGroup group = spawner.getGroup();
KList<IrisPosition> caveFloors = group == IrisSpawnGroup.CAVE KList<IrisPosition> caveFloors = group == IrisSpawnGroup.CAVE
? engine.getMantle().findMarkers(chunkX, chunkZ, MarkerMatter.CAVE_FLOOR) ? engine.getMantle().findMarkers(chunkX, chunkZ, MarkerMatter.CAVE_FLOOR)
: new KList<>(); : new KList<>();
if (context.initial()) {
caveFloors.sort(Comparator.comparingInt(IrisPosition::getX)
.thenComparingInt(IrisPosition::getY).thenComparingInt(IrisPosition::getZ));
}
int spawned = 0; int spawned = 0;
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
RNG entityRng = EntitySpawnSeed.entity(rng.getSeed(), i);
RNG positionRng = entityRng.nextParallelRNG(0x632BE59BD9B4E019L);
int worldX; int worldX;
int worldY; int worldY;
int worldZ; int worldZ;
@@ -600,16 +706,16 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (caveFloors.isEmpty()) { if (caveFloors.isEmpty()) {
continue; continue;
} }
IrisPosition caveFloor = caveFloors.getRandom(RNG.r); IrisPosition caveFloor = caveFloors.getRandom(positionRng);
worldX = caveFloor.getX(); worldX = caveFloor.getX();
worldY = caveFloor.getY(); worldY = caveFloor.getY();
worldZ = caveFloor.getZ(); worldZ = caveFloor.getZ();
} else { } else {
worldX = (chunkX << 4) + RNG.r.i(16); worldX = (chunkX << 4) + positionRng.i(16);
worldZ = (chunkZ << 4) + RNG.r.i(16); worldZ = (chunkZ << 4) + positionRng.i(16);
int surfaceY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, false); int surfaceY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, false);
int solidY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, true); int solidY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, true);
Integer selectedY = IrisEntitySpawn.selectSurfaceSpawnY(group, irisEntity.getSurface(), solidY, surfaceY, RNG.r); Integer selectedY = IrisEntitySpawn.selectSurfaceSpawnY(group, irisEntity.getSurface(), solidY, surfaceY, positionRng);
if (selectedY == null) { if (selectedY == null) {
continue; continue;
} }
@@ -619,7 +725,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
continue; continue;
} }
// Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity). // Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity).
if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) { if (!context.initial() && !lightAllowed(spawner, level, worldX, worldY, worldZ)) {
continue; continue;
} }
if (!surfaceMatches(irisEntity.getSurface(), level, worldX, worldY, worldZ)) { if (!surfaceMatches(irisEntity.getSurface(), level, worldX, worldY, worldZ)) {
@@ -635,7 +741,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
return spawned; return spawned;
} }
private int spawnEntryAt(NativeWorld level, IrisEntitySpawn entry, IrisSpawner spawner, IrisPosition position) { private int spawnEntryAt(NativeWorld level, IrisEntitySpawn entry, IrisPosition position, RNG rng, SpawnContext context) {
IrisEntity irisEntity = entry.getRealEntity(engine); IrisEntity irisEntity = entry.getRealEntity(engine);
if (irisEntity == null) { if (irisEntity == null) {
return 0; return 0;
@@ -643,21 +749,21 @@ public final class ModdedWorldManager implements EngineWorldManager {
int min = entry.getMinSpawns(); int min = entry.getMinSpawns();
int max = entry.getMaxSpawns(); int max = entry.getMaxSpawns();
int count = LootResolver.inclusive(RNG.r, min, max); int count = LootResolver.inclusive(rng, min, max);
if (count <= 0) { if (count <= 0) {
return 0; return 0;
} }
exhaustMarker(spawner, position); exhaustMarker(context.marker(), position, EntitySpawnSeed.entity(rng.getSeed(), -1));
RNG entityRng = entry.getRng().aquire(() -> new RNG(engine.getSeedManager().getEntity()));
int worldX = position.getX(); int worldX = position.getX();
int worldY = position.getY() + 1; int worldY = position.getY() + 1;
int worldZ = position.getZ(); int worldZ = position.getZ();
int spawned = 0; int spawned = 0;
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
RNG entityRng = EntitySpawnSeed.entity(rng.getSeed(), i);
// Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity). // Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity).
if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) { if (!context.initial() && !lightAllowed(context.spawner(), level, worldX, worldY, worldZ)) {
continue; continue;
} }
if (irisEntity.getSurface().isFluid() if (irisEntity.getSurface().isFluid()
@@ -672,16 +778,15 @@ public final class ModdedWorldManager implements EngineWorldManager {
return spawned; return spawned;
} }
private void exhaustMarker(IrisSpawner spawner, IrisPosition position) { private void exhaustMarker(IrisMarker marker, IrisPosition position, RNG rng) {
IrisMarker marker = spawner.getReferenceMarker(); if (marker == null || !marker.shouldExhaust(rng)) {
if (marker == null || !marker.shouldExhaust()) {
return; return;
} }
engine.getMantle().getMantle().remove(position.getX(), position.getY() - engine.getWorld().minHeight(), position.getZ(), MatterMarker.class); engine.getMantle().getMantle().remove(position.getX(), position.getY() - engine.getWorld().minHeight(), position.getZ(), MatterMarker.class);
} }
private boolean canSpawn(IrisSpawner spawner, int chunkX, int chunkZ) { private boolean canSpawn(IrisSpawner spawner, int chunkX, int chunkZ, boolean initial) {
return spawner.canSpawn(engine, chunkX, chunkZ); return !spawner.isCompatExcluded() && (initial || spawner.canSpawn(engine, chunkX, chunkZ));
} }
private boolean lightAllowed(IrisSpawner spawner, NativeWorld level, int worldX, int worldY, int worldZ) { private boolean lightAllowed(IrisSpawner spawner, NativeWorld level, int worldX, int worldY, int worldZ) {
@@ -796,9 +901,9 @@ public final class ModdedWorldManager implements EngineWorldManager {
return !studio || studioSetting; return !studio || studioSetting;
} }
private IrisEntitySpawn rarityPick(KList<IrisEntitySpawn> entries) { private static <T extends Rarity> T rarityPick(KList<T> entries, RNG rng) {
KList<IrisEntitySpawn> weighted = Rarity.expandWeighted(entries); KList<T> weighted = Rarity.expandWeighted(entries);
return weighted.isEmpty() ? entries.getRandom() : weighted.getRandom(); return weighted.isEmpty() ? entries.getRandom(rng) : weighted.getRandom(rng);
} }
private static long pack(int x, int z) { private static long pack(int x, int z) {
@@ -893,7 +998,14 @@ public final class ModdedWorldManager implements EngineWorldManager {
return failure; return failure;
} }
private record PreparedMarkerSpawn(IrisPosition position, IrisSpawner spawner, BiomeEnvironment environment) { private record PreparedMarkerSpawn(IrisPosition position, IrisSpawner spawner, IrisMarker marker, RNG rng, BiomeEnvironment environment) {
}
private record SpawnCandidate(IrisEntitySpawn entry, IrisSpawner spawner) implements Rarity {
@Override
public int getRarity() {
return entry.getRarity();
}
} }
private NativeEntityRuntime entityRuntime(NativeWorld level) { private NativeEntityRuntime entityRuntime(NativeWorld level) {
@@ -33,6 +33,7 @@ import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedDimensionManager;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import art.arcane.iris.modded.ModdedForcedDatapack; import art.arcane.iris.modded.ModdedForcedDatapack;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.iris.modded.ModdedScheduler; import art.arcane.iris.modded.ModdedScheduler;
@@ -445,6 +446,12 @@ public final class IrisModdedCommands {
); );
String completionMessage = downloadCompletionMessage(result); String completionMessage = downloadCompletionMessage(result);
if (result != null) { if (result != null) {
if (result.changed()) {
ModdedPackUpdateService updates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
if (updates != null) {
dispatchDownloadFeedback(source, updates::refresh);
}
}
if (completionMessage != null) { if (completionMessage != null) {
dispatchDownloadFeedback(source, () -> ok(source, completionMessage)); dispatchDownloadFeedback(source, () -> ok(source, completionMessage));
} }
@@ -31,6 +31,9 @@ import art.arcane.iris.generation.runtime.GenerationSessionException;
import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.structure.placement.IrisStructureLocator; import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.generation.locator.Locator; import art.arcane.iris.generation.locator.Locator;
import art.arcane.iris.generation.locator.BiomeLocator;
import art.arcane.iris.generation.subterrain.SubterrainPosition;
import art.arcane.iris.world.history.SavedBiomeRuntime;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy; import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability; import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.generation.runtime.WrongEngineBroException; import art.arcane.iris.generation.runtime.WrongEngineBroException;
@@ -82,7 +85,7 @@ final class ModdedLocateCommands {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNKNOWN_BIOME_2, MessageArgument.untrusted("key", key))); IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNKNOWN_BIOME_2, MessageArgument.untrusted("key", key)));
return 0; return 0;
} }
locate(source, level, engine, player, Locator.surfaceBiome(biome.getLoadKey()), "biome " + biome.getLoadKey()); locate(source, level, engine, player, BiomeLocator.forBiome(engine, biome.getLoadKey(), player.blockY()), "biome " + biome.getLoadKey());
return 1; return 1;
} }
@@ -545,12 +548,12 @@ final class ModdedLocateCommands {
previous.cancel(true); previous.cancel(true);
} }
search.whenComplete((Position2 at, Throwable error) -> completeLocate( search.whenComplete((Position2 at, Throwable error) -> completeLocate(
source, level, engine, player, label, server, playerId, search, at, error)); source, level, engine, player, label, server, playerId, search, locator, at, error));
} }
private static void completeLocate(NativeCommandSource source, NativeWorld level, Engine engine, private static void completeLocate(NativeCommandSource source, NativeWorld level, Engine engine,
NativeProtocolPlayer player, String label, NativeModdedServer server, UUID playerId, NativeProtocolPlayer player, String label, NativeModdedServer server, UUID playerId,
CompletableFuture<Position2> search, Position2 at, Throwable error) { CompletableFuture<Position2> search, Locator<?> locator, Position2 at, Throwable error) {
if (ACTIVE_LOCATE_REQUESTS.get(playerId) != search) { if (ACTIVE_LOCATE_REQUESTS.get(playerId) != search) {
return; return;
} }
@@ -576,15 +579,77 @@ final class ModdedLocateCommands {
}); });
return; return;
} }
if (locator instanceof BiomeLocator caveLocator) {
prepareCaveLocate(new CaveLocateRequest(source, level, engine, player, label, server, playerId,
search, caveLocator, at));
return;
}
server.execute(() -> { server.execute(() -> {
if (ACTIVE_LOCATE_REQUESTS.remove(playerId, search)) { if (ACTIVE_LOCATE_REQUESTS.remove(playerId, search)) {
teleportToLocateResult(source, level, engine, player, label, at); teleportToLocateResult(source, level, engine, player, label, locator, at, Optional.empty());
} }
}); });
} }
private static void teleportToLocateResult(NativeCommandSource source, NativeWorld level, Engine engine, private static void prepareCaveLocate(CaveLocateRequest request) {
NativeProtocolPlayer player, String label, Position2 at) { CompletableFuture.supplyAsync(() -> resolveCavePosition(request))
.whenComplete((target, error) -> request.server().execute(() -> loadCaveDestination(request, target, error)));
}
private static SubterrainPosition resolveCavePosition(CaveLocateRequest request) {
try (GenerationSessionLease lease = request.engine().acquireGenerationLease("modded_cave_position");
IrisContext.Scope ignored = IrisContext.open(request.engine(), lease.sessionId(), null)) {
return request.locator().position(request.engine(), request.at());
} catch (GenerationSessionException error) {
throw new CompletionException(error);
}
}
private static void loadCaveDestination(CaveLocateRequest request, SubterrainPosition target, Throwable error) {
if (ACTIVE_LOCATE_REQUESTS.get(request.playerId()) != request.search()) {
return;
}
if (error != null) {
failCaveLocate(request, error);
return;
}
if (target == null || !request.player().connected() || !request.player().inWorld(request.level())) {
if (ACTIVE_LOCATE_REQUESTS.remove(request.playerId(), request.search())) {
IrisModdedCommands.fail(request.source(), "The cave destination is no longer available.");
}
return;
}
try {
request.level().getBlock(target.x(), target.y(), target.z());
request.locator().prepareRead(request.engine(), new Position2(target.x() >> 4, target.z() >> 4))
.whenComplete((read, failure) -> request.server().execute(() -> finishCaveLocate(request, target, read, failure)));
} catch (RuntimeException failure) {
failCaveLocate(request, failure);
}
}
private static void finishCaveLocate(CaveLocateRequest request, SubterrainPosition target,
Optional<SavedBiomeRuntime.ReadSession> read, Throwable error) {
if (error != null) {
failCaveLocate(request, error);
return;
}
if (ACTIVE_LOCATE_REQUESTS.remove(request.playerId(), request.search())) {
teleportToLocateResult(request.source(), request.level(), request.engine(), request.player(),
request.label(), request.locator(), request.at(), Optional.of(new CaveDestination(target, read)));
}
}
private static void failCaveLocate(CaveLocateRequest request, Throwable error) {
ModdedIrisLog.error("Iris cave locate failed for {}", request.label(), error);
if (ACTIVE_LOCATE_REQUESTS.remove(request.playerId(), request.search())) {
IrisModdedCommands.fail(request.source(), "Could not prepare the cave destination. See the server log for details.");
}
}
static void teleportToLocateResult(NativeCommandSource source, NativeWorld level, Engine engine,
NativeProtocolPlayer player, String label, Locator<?> locator, Position2 at,
Optional<CaveDestination> caveDestination) {
// Same liveness guards the structure completion path has: the search can take up to // Same liveness guards the structure completion path has: the search can take up to
// two minutes, and the captured NativeProtocolPlayer may be gone or elsewhere by then. // two minutes, and the captured NativeProtocolPlayer may be gone or elsewhere by then.
if (!player.connected()) { if (!player.connected()) {
@@ -600,6 +665,17 @@ final class ModdedLocateCommands {
try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_locator_teleport"); try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_locator_teleport");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2; int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2;
if (locator instanceof BiomeLocator caveLocator) {
CaveDestination prepared = caveDestination.orElseThrow();
SubterrainPosition landing = caveLocator.landing(engine, level, prepared.target(), prepared.read());
if (landing == null) {
IrisModdedCommands.fail(source, "No safe cave landing found for " + label + ".");
return;
}
blockX = landing.x();
blockY = landing.y();
blockZ = landing.z();
}
boolean teleported = player.teleport(level, blockX + 0.5D, blockY, blockZ + 0.5D); boolean teleported = player.teleport(level, blockX + 0.5D, blockY, blockZ + 0.5D);
if (!teleported) { if (!teleported) {
IrisModdedCommands.fail(source, IrisLanguage.plain( IrisModdedCommands.fail(source, IrisLanguage.plain(
@@ -611,7 +687,8 @@ final class ModdedLocateCommands {
return; return;
} }
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORTED_AT_2, MessageArgument.untrusted("label", label), MessageArgument.untrusted("blockX", blockX), MessageArgument.untrusted("blockY", blockY), MessageArgument.untrusted("blockZ", blockZ))); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORTED_AT_2, MessageArgument.untrusted("label", label), MessageArgument.untrusted("blockX", blockX), MessageArgument.untrusted("blockY", blockY), MessageArgument.untrusted("blockZ", blockZ)));
} catch (GenerationSessionException e) { } catch (GenerationSessionException | RuntimeException e) {
ModdedIrisLog.error("Iris locate teleport failed for {}", label, e);
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_ENGINE_CHANGED_WHILE_LOCATING_TRY_AGAIN, MessageArgument.untrusted("label", label))); IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_ENGINE_CHANGED_WHILE_LOCATING_TRY_AGAIN, MessageArgument.untrusted("label", label)));
} }
} }
@@ -635,6 +712,14 @@ final class ModdedLocateCommands {
NO_PLACEMENT NO_PLACEMENT
} }
record CaveDestination(SubterrainPosition target, Optional<SavedBiomeRuntime.ReadSession> read) {
}
private record CaveLocateRequest(NativeCommandSource source, NativeWorld level, Engine engine,
NativeProtocolPlayer player, String label, NativeModdedServer server,
UUID playerId, CompletableFuture<Position2> search, BiomeLocator locator, Position2 at) {
}
private record NativeStructureTarget(String key, NativeStructureQueries.Reference holder, private record NativeStructureTarget(String key, NativeStructureQueries.Reference holder,
NativeStructureAvailability availability) { NativeStructureAvailability availability) {
} }
@@ -18,6 +18,8 @@
package art.arcane.iris.modded.command; package art.arcane.iris.modded.command;
import art.arcane.iris.modded.ModdedStartup;
import art.arcane.iris.modded.ModdedIrisLog; import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.pack.validation.PackCompatReport; import art.arcane.iris.pack.validation.PackCompatReport;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
@@ -25,7 +27,6 @@ import art.arcane.iris.pack.PackDirectoryResolver;
import art.arcane.iris.pack.PackResourceCleanup; import art.arcane.iris.pack.PackResourceCleanup;
import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedEngineBootstrap;
import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.builder.LiteralArgumentBuilder;
@@ -138,8 +139,7 @@ public final class ModdedPackCommands {
int broken = 0; int broken = 0;
for (File target : targets) { for (File target : targets) {
try { try {
PackValidationResult result = PackValidator.validate(target); PackValidationResult result = ModdedStartup.validatePack(target);
PackValidationRegistry.publish(result);
if (!result.isLoadable()) { if (!result.isLoadable()) {
broken++; broken++;
} }
@@ -22,6 +22,7 @@ import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime; import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime;
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad; import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.world.pregen.MantleHeapPressure;
import art.arcane.iris.world.pregen.PregenListener; import art.arcane.iris.world.pregen.PregenListener;
import art.arcane.iris.world.pregen.PregenMantleBackpressure; import art.arcane.iris.world.pregen.PregenMantleBackpressure;
import art.arcane.iris.world.pregen.PregeneratorMethod; import art.arcane.iris.world.pregen.PregeneratorMethod;
@@ -74,8 +75,8 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
sync = configuration.sync(); sync = configuration.sync();
pauseGuard = new PauseWhenEmptyGuard(runtime); pauseGuard = new PauseWhenEmptyGuard(runtime);
IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen(); IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen();
this.maxInFlight = Math.max(8, pregen.getModdedPregenInFlight()); this.maxInFlight = memoryLimitedConcurrency(pregen.getModdedPregenInFlight(), MantleHeapPressure.memoryBudgetBytes());
this.minInFlight = Math.max(4, Math.min(16, maxInFlight / 4)); this.minInFlight = Math.min(maxInFlight, Math.max(4, Math.min(16, maxInFlight / 4)));
this.semaphore = new Semaphore(maxInFlight, true); this.semaphore = new Semaphore(maxInFlight, true);
this.adaptiveLimit = new AtomicInteger(sync ? 1 : maxInFlight); this.adaptiveLimit = new AtomicInteger(sync ? 1 : maxInFlight);
this.timeoutSeconds = Math.max(120, pregen.getChunkLoadTimeoutSeconds()); this.timeoutSeconds = Math.max(120, pregen.getChunkLoadTimeoutSeconds());
@@ -86,7 +87,13 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
pregen.getMantleBackpressureTimeoutMs(), pregen.getMantleBackpressureTimeoutMs(),
() -> { () -> {
}, },
() -> "dim=" + runtime.worldIdentity()); () -> "dim=" + runtime.worldIdentity(),
this::admissionCancelled);
}
static int memoryLimitedConcurrency(int configuredLimit, long memoryBudgetBytes) {
long memoryLimit = Math.max(1L, memoryBudgetBytes / (256L * 1024L * 1024L));
return (int) Math.min(Math.max(1, configuredLimit), memoryLimit);
} }
@Override @Override
@@ -359,12 +366,26 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
} }
listener.onChunkGenerating(x, z); listener.onChunkGenerating(x, z);
try { try {
while (true) {
synchronized (permitMonitor) { synchronized (permitMonitor) {
while (inFlight.get() >= adaptiveLimit.get()) { while (inFlight.get() >= adaptiveLimit.get()) {
if (admissionCancelled()) {
return;
}
permitMonitor.wait(500L); permitMonitor.wait(500L);
} }
} }
semaphore.acquire(); semaphore.acquire();
if (admissionCancelled()) {
semaphore.release();
return;
}
if (!MantleHeapPressure.overHighWater()) {
break;
}
semaphore.release();
backpressure.awaitHeapHeadroom();
}
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
return; return;
@@ -400,6 +421,10 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
}); });
} }
private boolean admissionCancelled() {
return Thread.currentThread().isInterrupted() || !runtime.isRunning();
}
private void markSubmitted() { private void markSubmitted() {
int current = inFlight.incrementAndGet(); int current = inFlight.incrementAndGet();
inFlightPeak.accumulateAndGet(current, Math::max); inFlightPeak.accumulateAndGet(current, Math::max);
@@ -0,0 +1,138 @@
package art.arcane.iris.modded.service;
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner;
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner.SplashPackMetadata;
import art.arcane.iris.pack.BuiltInPackUpdates;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.io.File;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Function;
public final class ModdedPackUpdateService implements ModdedTickableService {
private final Function<File, CompletableFuture<PackCheck>> checker;
private final Set<UUID> notified = new HashSet<>();
private CompletableFuture<PackCheck> pending;
private List<String> notices = List.of();
private File packsFolder;
private boolean enabled;
private boolean started;
public ModdedPackUpdateService() {
this(ModdedPackUpdateService::checkPacks);
}
ModdedPackUpdateService(Function<File, CompletableFuture<PackCheck>> checker) {
this.checker = checker;
}
@Override
public void onEnable() {
enabled = true;
}
@Override
public void onDisable() {
enabled = false;
if (pending != null) {
pending.cancel(true);
pending = null;
}
started = false;
notices = List.of();
notified.clear();
packsFolder = null;
}
@Override
public void onServerTick(NativeModdedServer server) {
if (!enabled || server == null || !server.hasPlayerList()) {
return;
}
if (!started) {
started = true;
packsFolder = IrisPlatforms.get().packsFolderNoCreate().getAbsoluteFile();
pending = checker.apply(packsFolder);
}
if (pending == null || !pending.isDone()) {
return;
}
CompletableFuture<PackCheck> completed = pending;
pending = null;
try {
publish(completed.join());
server.forEachPlayer(this::notifyPlayer);
} catch (CompletionException error) {
IrisLogging.reportError("Iris pack update check failed", error);
}
}
public void refresh() {
if (!enabled) {
return;
}
if (pending != null) {
pending.cancel(true);
pending = null;
}
started = false;
notices = List.of();
notified.clear();
}
public void notifyPlayer(NativeProtocolPlayer player) {
if (!enabled || notices.isEmpty() || player == null || !player.connected()
|| (!player.isGameMaster() && !player.isServerOwner()) || !notified.add(player.id())) {
return;
}
for (String notice : notices) {
player.sendMessage(notice);
}
}
private static CompletableFuture<PackCheck> checkPacks(File folder) {
return CompletableFuture.supplyAsync(() -> IrisSplashPackScanner.collect(folder, IrisLogging::reportError))
.thenCompose(packs -> BuiltInPackUpdates.check(packs.stream().map(SplashPackMetadata::name).toList())
.thenApply(updates -> new PackCheck(packs, updates)));
}
private void publish(PackCheck result) {
List<String> updates = new ArrayList<>();
IrisLogging.info("Iris packs: " + packsFolder);
for (SplashPackMetadata pack : result.packs()) {
BuiltInPackUpdates.Update update = result.updates().get(pack.name());
String version = pack.version().matches("[0-9]+") ? "v" + pack.version() : "version unknown";
IrisLogging.info(" " + pack.name() + " " + version
+ (update == null ? "" : update.suffix(pack.version())));
if (update != null && update.newerThan(pack.version())) {
updates.add("Iris " + pack.name() + " release metadata is outdated: installed v" + pack.version()
+ ", latest built-in release v" + update.version() + ".");
updates.add("Back up and review custom edits in " + new File(packsFolder, pack.name())
+ "; close Studio, then update with /iris download pack=" + pack.name() + " overwrite=true.");
updates.add("Existing worlds retain their recorded production pack. Back up the complete world, then stage an update with /iris world update <dimension> "
+ pack.name() + " and restart the server to activate it.");
}
}
notices = List.copyOf(updates);
for (String notice : notices) {
IrisLogging.warn(notice);
}
}
record PackCheck(List<SplashPackMetadata> packs, Map<String, BuiltInPackUpdates.Update> updates) {
PackCheck {
packs = List.copyOf(packs);
updates = Map.copyOf(updates);
}
}
}
@@ -57,6 +57,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
public final class ModdedStudioHotloadService implements ModdedTickableService, EnginePlatformHooks { public final class ModdedStudioHotloadService implements ModdedTickableService, EnginePlatformHooks {
@@ -64,6 +65,8 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
private static final long POLL_MILLIS = 250L; private static final long POLL_MILLIS = 250L;
private static final long CHECK_LATCH_MILLIS = 1_000L; private static final long CHECK_LATCH_MILLIS = 1_000L;
private static final long RECENT_GENERATION_HOLDOFF_MILLIS = 2_000L; private static final long RECENT_GENERATION_HOLDOFF_MILLIS = 2_000L;
private static final long SHUTDOWN_TIMEOUT_SECONDS = 30L;
private static final long INTERRUPT_DRAIN_TIMEOUT_SECONDS = 5L;
private final ConcurrentHashMap<String, Watch> watches = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Watch> watches = new ConcurrentHashMap<>();
private volatile ExecutorService executor; private volatile ExecutorService executor;
@@ -81,9 +84,13 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override @Override
public void onEnable() { public void onEnable() {
if (executor != null) { ExecutorService current = executor;
if (current != null && !current.isTerminated()) {
if (!current.isShutdown()) {
return; return;
} }
throw new IllegalStateException("Iris Studio hotload cannot restart while prior workers are still active");
}
executor = Executors.newSingleThreadExecutor((Runnable task) -> { executor = Executors.newSingleThreadExecutor((Runnable task) -> {
Thread thread = new Thread(task, "Iris Studio Hotload"); Thread thread = new Thread(task, "Iris Studio Hotload");
thread.setDaemon(true); thread.setDaemon(true);
@@ -96,10 +103,10 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override @Override
public void onDisable() { public void onDisable() {
ExecutorService active = executor; ExecutorService active = executor;
executor = null; if (!shutdownAndDrain(active)) {
if (active != null) { throw new IllegalStateException("Iris Studio hotload worker remained active during service shutdown");
active.shutdownNow();
} }
executor = null;
for (Watch watch : watches.values()) { for (Watch watch : watches.values()) {
watch.close(); watch.close();
} }
@@ -197,7 +204,7 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override @Override
public void onServerTick(NativeModdedServer server) { public void onServerTick(NativeModdedServer server) {
ExecutorService active = executor; ExecutorService active = executor;
if (active == null) { if (active == null || active.isShutdown()) {
return; return;
} }
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
@@ -284,6 +291,30 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
&& !isPregeneratorActive(engine); && !isPregeneratorActive(engine);
} }
private static boolean shutdownAndDrain(ExecutorService active) {
if (active == null) {
return true;
}
active.shutdown();
try {
if (active.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
return true;
}
active.shutdownNow();
if (active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
return true;
}
ModdedIrisLog.error("Iris Studio hotload worker did not stop after shutdownNow",
new IllegalStateException("Iris Studio hotload worker is still active"));
return false;
} catch (InterruptedException exception) {
active.shutdownNow();
Thread.currentThread().interrupt();
ModdedIrisLog.error("Interrupted while draining Iris Studio hotload", exception);
return false;
}
}
private boolean throttled(IrisModdedChunkGenerator generator, Engine engine, long now) { private boolean throttled(IrisModdedChunkGenerator generator, Engine engine, long now) {
if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) { if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) {
return true; return true;
@@ -38,12 +38,18 @@ public class IrisModdedStructureParityTest {
Bootstrap.bootStrap(); Bootstrap.bootStrap();
} }
private static Engine surfaceEngine(int minHeight) {
Engine engine = mock(Engine.class);
when(engine.getMinHeight()).thenReturn(minHeight);
return engine;
}
@Test @Test
public void surfaceBiomeFastPathBeginsAboveTheCaveSwitch() { public void surfaceBiomeFastPathBeginsAboveTheCaveSwitch() {
assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(-2, -256)); assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(-256), -2));
assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(-1, -256)); assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(-256), -1));
assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(10, 0)); assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(0), 10));
assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(11, 0)); assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(0), 11));
} }
@Test @Test
@@ -57,8 +57,11 @@ public class ModdedBindFailureTest {
when(server.generateStructures()).thenReturn(false); when(server.generateStructures()).thenReturn(false);
try (MockedStatic<NativeModdedServer> servers = mockStatic(NativeModdedServer.class); try (MockedStatic<NativeModdedServer> servers = mockStatic(NativeModdedServer.class);
MockedStatic<ModdedWorldEngines> engines = mockStatic(ModdedWorldEngines.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) { MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server); servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
engines.when(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any()))
.thenThrow(new IllegalStateException("broken world binding"));
IllegalStateException recorded = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); IllegalStateException recorded = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
IllegalStateException second = assertThrows(IllegalStateException.class, generator::engine); IllegalStateException second = assertThrows(IllegalStateException.class, generator::engine);
@@ -66,9 +69,9 @@ public class ModdedBindFailureTest {
assertSame(recorded, second.getCause()); assertSame(recorded, second.getCause());
assertSame(recorded, third.getCause()); assertSame(recorded, third.getCause());
servers.verify(() -> NativeModdedServer.forWorld(level), times(1)); engines.verify(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any()), times(1));
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ") log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
&& line.contains("generate-structures=false"))), times(1)); && line.contains("broken world binding"))), times(1));
} }
} }
@@ -117,13 +120,15 @@ public class ModdedBindFailureTest {
MockedStatic<ModdedWorldEngines> engines = mockStatic(ModdedWorldEngines.class); MockedStatic<ModdedWorldEngines> engines = mockStatic(ModdedWorldEngines.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) { MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server); servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
engines.when(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any()))
.thenAnswer(invocation -> { throw new IllegalStateException("broken world binding"); });
IllegalStateException first = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); IllegalStateException first = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
generator.unbindEngine(level); generator.unbindEngine(level);
IllegalStateException second = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); IllegalStateException second = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
assertNotSame(first, second.getCause()); assertNotSame(first, second.getCause());
servers.verify(() -> NativeModdedServer.forWorld(level), times(2)); engines.verify(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any()), times(2));
engines.verify(() -> ModdedWorldEngines.evictOrThrow(level)); engines.verify(() -> ModdedWorldEngines.evictOrThrow(level));
} }
} }
@@ -2,6 +2,11 @@ package art.arcane.iris.modded;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.DimensionStackLayout;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.subterrain.IrisSubterrainFeature;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.generation.runtime.GenerationSessionException; import art.arcane.iris.generation.runtime.GenerationSessionException;
import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.IrisComplex;
@@ -11,20 +16,54 @@ import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import org.junit.Test; import org.junit.Test;
import java.util.ArrayList; import java.util.ArrayList;
import java.io.File;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer; import java.util.function.Consumer;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class ModdedBiomePolicyTest { public class ModdedBiomePolicyTest {
@Test
public void authoredBiomeWinsAboveSurfaceGateAndBeforeStackOwnership() throws Exception {
Engine engine = mock(Engine.class);
IrisBiome feature = mock(IrisBiome.class);
IrisDimension host = new IrisDimension().setSubterrainFeatures(new KList<>(
new IrisSubterrainFeature().setId("room")));
IrisData data = mock(IrisData.class);
when(data.getDataFolder()).thenReturn(new File("synthetic-pack"));
when(engine.getData()).thenReturn(data);
when(engine.getDimension()).thenReturn(host);
when(engine.getComplex()).thenReturn(mock(IrisComplex.class));
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getSubterrainBiome(12, 224, -20)).thenReturn(feature);
assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(engine, 40));
ModdedBiomePolicy<BiomeToken, Object> policy = boundTo(engine);
DimensionStackLayout stack = mock(DimensionStackLayout.class);
Method method = ModdedBiomePolicy.class.getDeclaredMethod("resolveBiomeResolution", Engine.class,
int.class, int.class, int.class, boolean.class, DimensionStackLayout.class);
method.setAccessible(true);
Object resolution = method.invoke(policy, engine, 3, 40, -5, true, stack);
Field biome = resolution.getClass().getDeclaredField("irisBiome");
biome.setAccessible(true);
assertSame(feature, biome.get(resolution));
Field dimension = resolution.getClass().getDeclaredField("dimension");
dimension.setAccessible(true);
assertSame(host, dimension.get(resolution));
verifyNoInteractions(stack);
}
@Test @Test
public void registryOrderAndHolderIdentitySurviveThePolicyBoundaryAndRefreshOnRepoint() { public void registryOrderAndHolderIdentitySurviveThePolicyBoundaryAndRefreshOnRepoint() {
BiomeToken ocean = new BiomeToken("minecraft:ocean"); BiomeToken ocean = new BiomeToken("minecraft:ocean");
@@ -18,6 +18,21 @@
package art.arcane.iris.modded; package art.arcane.iris.modded;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.block.IrisBlockData;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.world.loot.IrisBlockDrops;
import art.arcane.iris.world.loot.IrisLoot;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeItemStack;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldGenerators;
import art.arcane.volmlib.nativelib.terrain.NativeBlockPoint;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import net.minecraft.core.Direction; import net.minecraft.core.Direction;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState; import art.arcane.volmlib.nativelib.minecraft26_2.modded.ModdedBlockState;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
@@ -27,9 +42,22 @@ import org.junit.Test;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import net.minecraft.SharedConstants; import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap; import net.minecraft.server.Bootstrap;
import org.mockito.MockedStatic;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedBlockBreakHandlerTest { public class ModdedBlockBreakHandlerTest {
@BeforeClass @BeforeClass
@@ -48,4 +76,97 @@ public class ModdedBlockBreakHandlerTest {
assertTrue(ModdedBlockBreakHandler.matchesState(north, east, false)); assertTrue(ModdedBlockBreakHandler.matchesState(north, east, false));
assertFalse(ModdedBlockBreakHandler.matchesState(north, ModdedBlockState.of(Blocks.COBBLESTONE.defaultBlockState(), null), false)); assertFalse(ModdedBlockBreakHandler.matchesState(north, ModdedBlockState.of(Blocks.COBBLESTONE.defaultBlockState(), null), false));
} }
@Test
public void historicalEnvironmentSuppliesAllRulesAndScopesMatchingAndLoot() {
try (DropFixture fixture = new DropFixture()) {
fixture.dimensionRule.setReplaceVanillaDrops(true);
ModdedBlockBreakHandler.Result result = fixture.evaluate();
assertTrue(result.replaceVanillaDrops());
assertEquals(3, result.drops().size());
assertSame(fixture.item, result.drops().getFirst());
assertFalse(fixture.scoped.get());
verify(fixture.engine).getBiomeEnvironment(16, 136, 32);
verify(fixture.engine).openBiomeEnvironmentScope(fixture.environment);
verify(fixture.engine, never()).getData();
verify(fixture.engine, never()).getDimension();
verify(fixture.engine, never()).getBiome(anyInt(), anyInt(), anyInt());
verify(fixture.engine, never()).getRegion(anyInt(), anyInt(), anyInt());
}
}
@Test
public void historicalBiomeCanSkipRegionAndDimensionDrops() {
try (DropFixture fixture = new DropFixture()) {
fixture.biomeRule.setSkipParents(true);
fixture.dimensionRule.setReplaceVanillaDrops(true);
ModdedBlockBreakHandler.Result result = fixture.evaluate();
assertFalse(result.replaceVanillaDrops());
assertEquals(1, result.drops().size());
assertFalse(fixture.scoped.get());
verify(fixture.region, never()).getBlockDrops();
verify(fixture.dimension, never()).getBlockDrops();
}
}
private static final class DropFixture implements AutoCloseable {
private final NativeWorld world = mock(NativeWorld.class);
private final Engine engine = mock(Engine.class);
private final IrisBiome biome = mock(IrisBiome.class);
private final IrisRegion region = mock(IrisRegion.class);
private final IrisDimension dimension = mock(IrisDimension.class);
private final IrisData data = mock(IrisData.class);
private final BiomeEnvironment environment = new BiomeEnvironment(7L, biome, region, dimension, data);
private final ModdedBlockState state = ModdedBlockState.of(Blocks.COBBLESTONE.defaultBlockState(), null);
private final NativeItemStack item = mock(NativeItemStack.class);
private final IrisLoot loot = new IrisLoot().setRarity(1);
private final IrisBlockData configuredBlock = mock(IrisBlockData.class);
private final IrisBlockDrops biomeRule = rule();
private final IrisBlockDrops regionRule = rule();
private final IrisBlockDrops dimensionRule = rule();
private final AtomicBoolean scoped = new AtomicBoolean();
private final MockedStatic<NativeWorldGenerators> generators = mockStatic(NativeWorldGenerators.class);
private final MockedStatic<ModdedItemTranslator> translator = mockStatic(ModdedItemTranslator.class);
private DropFixture() {
IrisModdedChunkGenerator generator = mock(IrisModdedChunkGenerator.class);
generators.when(() -> NativeWorldGenerators.find(world, IrisModdedChunkGenerator.class)).thenReturn(generator);
when(generator.engineIfBound()).thenReturn(engine);
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getBiomeEnvironment(16, 136, 32)).thenReturn(environment);
when(engine.openBiomeEnvironmentScope(environment)).thenAnswer(invocation -> {
assertTrue(scoped.compareAndSet(false, true));
return (BiomeEnvironment.Scope) () -> scoped.set(false);
});
when(biome.getBlockDrops()).thenReturn(new KList<>(biomeRule));
when(region.getBlockDrops()).thenReturn(new KList<>(regionRule));
when(dimension.getBlockDrops()).thenReturn(new KList<>(dimensionRule));
when(configuredBlock.getBlockData(data)).thenAnswer(invocation -> {
assertTrue(scoped.get());
return state;
});
translator.when(() -> ModdedItemTranslator.stack(eq(loot), eq(RNG.r), any())).thenAnswer(invocation -> {
assertTrue(scoped.get());
return item;
});
}
private IrisBlockDrops rule() {
return new IrisBlockDrops().setBlocks(new KList<>(configuredBlock)).setDrops(new KList<>(loot));
}
private ModdedBlockBreakHandler.Result evaluate() {
return ModdedBlockBreakHandler.evaluateManagedDrops(world, new NativeBlockPoint(16, 72, 32), state);
}
@Override
public void close() {
translator.close();
generators.close();
}
}
} }
@@ -39,6 +39,37 @@ public class ModdedClientPackageIsolationTest {
"art/arcane/iris/nativegen"); "art/arcane/iris/nativegen");
private static final int MINIMUM_SCANNED = 50; private static final int MINIMUM_SCANNED = 50;
@Test
public void clientPresentationCannotAccessPackOrWorldPersistence() throws IOException, URISyntaxException {
Path root = classesRoot();
Path directory = root.resolve(CLIENT_IRIS);
List<String> forbidden = List.of(
"art/arcane/iris/pack/",
"art/arcane/iris/world/storage/",
"art/arcane/iris/world/history/",
"art/arcane/iris/generation/runtime/",
"art/arcane/iris/configuration/",
"java/nio/file/",
"java/nio/channels/FileChannel",
"java/io/File",
"java/io/RandomAccessFile");
List<String> violations = new ArrayList<>();
int scanned = 0;
try (Stream<Path> walk = Files.walk(directory)) {
for (Path classFile : walk.filter(ModdedClientPackageIsolationTest::isClassFile).toList()) {
scanned++;
String bytecode = readAsLatin1(classFile);
for (String dependency : forbidden) {
if (bytecode.contains(dependency)) {
violations.add(root.relativize(classFile) + " -> " + dependency);
}
}
}
}
assertTrue("client presentation classes were not scanned", scanned >= 20);
assertEquals("client presentation can access persistent generation data: " + violations, List.of(), violations);
}
@Test @Test
public void serverSidePackagesNeverReferenceClientOnlyTypes() throws IOException, URISyntaxException { public void serverSidePackagesNeverReferenceClientOnlyTypes() throws IOException, URISyntaxException {
Path root = classesRoot(); Path root = classesRoot();
@@ -0,0 +1,44 @@
package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockFaces;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.hunk.Hunk;
import org.junit.Test;
import org.mockito.MockedStatic;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedDecoratorHooksTest {
@Test
public void faceFixingExcludesTheUpperBoundaryWhileKeepingTheLastRowAccessible() {
NativeBlockState state = mock(NativeBlockState.class);
NativeBlockState lastRow = mock(NativeBlockState.class);
Hunk<NativeBlockState> hunk = mock(Hunk.class);
when(hunk.getHeight()).thenReturn(4);
when(hunk.get(1, 3, 2)).thenReturn(lastRow);
when(hunk.get(1, 4, 2)).thenThrow(new IndexOutOfBoundsException("upper boundary"));
try (MockedStatic<NativeBlockFaces> faces = mockStatic(NativeBlockFaces.class)) {
faces.when(() -> NativeBlockFaces.fixFaces(eq(state), any(NativeBlockFaces.Neighbors.class)))
.thenAnswer(invocation -> {
NativeBlockFaces.Neighbors neighbors = invocation.getArgument(1);
assertNull(neighbors.secondary(0, 1, 0));
assertSame(lastRow, neighbors.secondary(0, 0, 0));
return state;
});
assertSame(state, new ModdedDecoratorHooks().fixFaces(state, hunk, 1, 2, 99, 3, 108, null));
faces.verify(() -> NativeBlockFaces.fixFaces(eq(state), any(NativeBlockFaces.Neighbors.class)));
}
verify(hunk, never()).get(1, 4, 2);
verify(hunk).get(1, 3, 2);
}
}
Loaded 100 of 463 files, more files were not shown because too many files have changed in this diff. Show more