Compare commits

...
Author SHA1 Message Date
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
406 changed files with 19009 additions and 5613 deletions
+5
View File
@@ -76,3 +76,8 @@ BUILDS/
.compatwt/
.worktrees/
/VolmLib/
/docs/
/.perf/
/.codegraph/
@@ -14,6 +14,7 @@ import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.levelgen.Heightmap;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
@@ -45,6 +46,11 @@ public final class IrisChunkGeneratorHeightCacheTest {
NativeBlockTags.bindHeightmapFixtures();
}
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test
public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception {
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.LiquidBlock;
import net.minecraft.world.level.levelgen.Heightmap;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
@@ -46,6 +47,11 @@ public final class NativeTransitionColumnTest {
NativeBlockTags.bindHeightmapFixtures();
}
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test
public void customKeysUseCarrierForNativeColumnsAndHeightmaps() {
String key = "itemsadder:rocks/ruby_ore";
@@ -1,21 +1,30 @@
package art.arcane.iris.nativegen.v26_3_R1;
import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries;
import net.minecraft.tags.BlockTags;
import net.minecraft.tags.TagLoader;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class NativeBlockTags {
private static Map<TagKey<Block>, List<Holder<Block>>> originalTags;
private NativeBlockTags() {
}
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(),
Blocks.OAK_LOG.builtInRegistryHolder(), Blocks.SPRUCE_LOG.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_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.SwampHutPiece;
import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
@@ -68,6 +69,11 @@ public class WorldgenTerrainHeightmapsTest {
NativeBlockTags.bindHeightmapFixtures();
}
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test
public void authoringHeightmapsReadPlacedObjectsAndFluidsWithoutTerrainResolvers() {
ProtoChunk chunk = terrainChunk(new ChunkPos(0, 0));
@@ -13,6 +13,7 @@ import com.google.gson.JsonParser;
import org.bukkit.Bukkit;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import java.io.File;
@@ -26,6 +27,9 @@ import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
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 NATURAL_MOB_SPAWNS = "minecraft:gameplay/natural_mob_spawns";
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.storage.LevelStorageSource;
import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.bukkit.craftbukkit.generator.CustomChunkGenerator;
@@ -49,6 +50,9 @@ import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
public class HeadlessChunkGeneratorTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule
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 org.bukkit.Bukkit;
import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
@@ -48,6 +49,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public final class HeadlessChunkLightingTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule
public TemporaryFolder temporary = new TemporaryFolder();
@@ -38,6 +38,7 @@ import net.minecraft.world.ticks.ScheduledTick;
import org.bukkit.NamespacedKey;
import org.bukkit.persistence.PersistentDataType;
import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
@@ -54,6 +55,9 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class HeadlessChunkSerializationTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule
public TemporaryFolder temporary = new TemporaryFolder();
@@ -9,6 +9,7 @@ import net.minecraft.world.item.Items;
import java.util.Map;
import net.minecraft.world.level.block.Blocks;
import org.junit.ClassRule;
import org.junit.Test;
import org.bukkit.Bukkit;
@@ -18,6 +19,9 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public final class HeadlessNativeBootstrapTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Test
public void bindsNativeItemComponentsWithoutStartingServer() throws Exception {
HeadlessNativeBootstrap.bindComponents(HeadlessNativeTestRegistries.get());
@@ -7,12 +7,16 @@ import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.levelgen.structure.Structure;
import org.junit.ClassRule;
import org.junit.Test;
import static org.junit.Assert.assertTrue;
public final class HeadlessNativeRegistriesTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Test
public void nativeStructureBiomeTagsUseTheLoadedBiomeHolders() throws Exception {
RegistryAccess registries = HeadlessNativeTestRegistries.get();
@@ -4,6 +4,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
import net.minecraft.core.registries.Registries;
import org.bukkit.Bukkit;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import java.util.Map;
@@ -17,6 +18,9 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
public final class HeadlessNativeRegistryTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass
public static void bootstrap() {
HeadlessNativeBootstrap.initialize();
@@ -11,6 +11,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeTileReader;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.data.registries.VanillaRegistries;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import java.io.ByteArrayInputStream;
@@ -23,6 +24,9 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
public final class HeadlessNativeTileTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass
public static void bootstrap() {
HeadlessNativeBootstrap.initialize();
@@ -17,6 +17,7 @@ import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.persistence.PersistentDataType;
import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.spigotmc.SpigotWorldConfig;
@@ -36,6 +37,9 @@ import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public final class HeadlessRegionTerrainTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule
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.RegionStorageInfo;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import java.io.DataInputStream;
@@ -38,6 +39,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public final class NativeRegionTerrainWriterTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
@@ -26,6 +26,7 @@ import net.minecraft.world.level.material.Fluids;
import org.bukkit.Bukkit;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import java.util.ArrayList;
@@ -40,6 +41,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public final class OfflineNativeFeatureFeasibilityTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static HeadlessNativeRegistries loaded;
private static RegistryAccess registries;
private static DimensionType dimension;
@@ -1,6 +1,7 @@
package art.arcane.iris.probe;
import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
@@ -22,6 +23,9 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public final class OfflineRegionPublicationTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule
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;
import java.util.concurrent.CompletionStage;
import art.arcane.volmlib.util.diagnostics.BukkitDebugDump;
import art.arcane.iris.generation.runtime.IrisEngineEffects;
import art.arcane.iris.generation.runtime.IrisWorldManager;
@@ -1011,20 +1012,21 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
@Override
public void onPreUnload(ReloadAware.PreUnloadReason reason) {
public CompletionStage<Void> commitReload(ReloadAware.PreUnloadReason reason) {
teardownPapi();
if (IrisToolbelt.isServerStopping()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
startPostStopFinisher();
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
return;
return CompletableFuture.completedFuture(null);
}
if (alreadyDrained.get()) {
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.");
drainOnce("pre-unload:" + reason, 45L);
return CompletableFuture.completedFuture(null);
}
/**
@@ -25,6 +25,8 @@ import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.studio.object.ObjectStudioSaveService;
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.nativegen.NativeStructureGenerationPolicy;
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")
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(
@Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class)
IrisBiome biome,
@@ -89,6 +91,78 @@ public class CommandFind implements DirectorExecutor {
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")
public void region(
@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;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.Iris;
import art.arcane.iris.pack.validation.PackCompatReport;
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))));
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()))));
reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED);
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))));
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()))));
reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED);
return;
@@ -363,47 +363,79 @@ public class CommandPack implements DirectorExecutor {
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 {
PackValidationResult result = PackValidator.validate(packFolder);
PackValidationRegistry.publish(result);
reportResult(s, result);
return result;
if (ticket == null) {
throw new IllegalStateException("Pack content is currently being changed.");
}
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) {
Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e);
String detail = e.getMessage() == null || e.getMessage().isBlank()
? e.getClass().getSimpleName()
: e.getMessage();
PackValidationResult result = new PackValidationResult(
result = new PackValidationResult(
packFolder.getName(),
List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail),
List.of(),
System.currentTimeMillis());
PackValidationRegistry.publish(result);
reportResult(s, result);
return result;
contentFingerprint = "";
contextFingerprint = "";
}
if (PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) {
PackValidationRegistry.publish(result);
} else {
result = new PackValidationResult(packFolder.getName(),
List.of("Pack content changed during validation."), List.of(), System.currentTimeMillis());
}
reportResult(s, result);
return result;
}
private void persistValidationCache(File packsRoot) {
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
for (File packDirectory : packDirectories) {
PackValidationResult result = PackValidationRegistry.get(packDirectory.getName());
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()
+ "\" has no registered result. Run /iris pack validate all to repopulate it.");
try {
String contextFingerprint = PackValidationCache.contextFingerprint();
PackFingerprints.PackContentSnapshot snapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
for (File packDirectory : packDirectories) {
PackValidationResult result = PackValidationRegistry.getMatching(
packDirectory.toPath(), snapshot.packContents().get(packDirectory.getName()));
if (result == null) {
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
+ "\" has no validation matching its current content. Run /iris pack validate to refresh it.");
return;
}
results.add(result);
}
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;
}
results.add(result);
}
try {
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(
Iris.instance.getDataFile("cache", "pack-validation.json").toPath(),
PackValidationCache.contentFingerprint(packsRoot),
PackValidationCache.contextFingerprint(),
snapshot.content(),
contextFingerprint,
results);
} catch (IOException | RuntimeException e) {
Iris.reportError("Could not persist refreshed pack-validation results", e);
@@ -18,8 +18,9 @@
package art.arcane.iris.world;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.Iris;
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();
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
ServerConfigurator.PackContentSnapshot contentSnapshot =
new ServerConfigurator.PackContentSnapshot("", Map.of());
PackFingerprints.PackContentSnapshot contentSnapshot =
new PackFingerprints.PackContentSnapshot("", Map.of());
String contextFingerprint = "";
Optional<List<PackValidationResult>> cached = Optional.empty();
try {
contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot);
contentSnapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
contextFingerprint = PackValidationCache.contextFingerprint();
if (!externalContentChanged) {
cached = PackValidationCache.load(
@@ -274,19 +275,19 @@ public final class IrisWorldGeneratorResolver {
private static FreshValidation validateStablePacks(
File packsRoot,
List<File> initialPackDirs,
ServerConfigurator.PackContentSnapshot initialSnapshot
PackFingerprints.PackContentSnapshot initialSnapshot
) {
List<File> packDirs = initialPackDirs;
ServerConfigurator.PackContentSnapshot before = initialSnapshot;
PackFingerprints.PackContentSnapshot before = initialSnapshot;
for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) {
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
List<PackValidationResult> results = validatePacks(packDirs);
ServerConfigurator.PackContentSnapshot after;
PackFingerprints.PackContentSnapshot after;
try {
after = ServerConfigurator.computePackContentSnapshot(packsRoot);
after = PackFingerprints.computePackContentSnapshot(packsRoot);
} catch (RuntimeException 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<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList();
@@ -923,7 +924,7 @@ public final class IrisWorldGeneratorResolver {
private record FreshValidation(
List<File> packDirs,
ServerConfigurator.PackContentSnapshot contentSnapshot,
PackFingerprints.PackContentSnapshot contentSnapshot,
List<PackValidationResult> results,
boolean stable
) {
@@ -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.IrisCreator;
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.block.Biome;
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
@@ -19,6 +26,10 @@ import org.bukkit.command.CommandSender;
import org.bukkit.generator.ChunkGenerator;
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.List;
import java.util.logging.Level;
@@ -26,6 +37,7 @@ import java.util.logging.Level;
public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution {
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 COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe");
private static final long WORLD_SEED = 78264193L;
private NativeWorldRuntime runtime;
@@ -45,7 +57,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
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;
}
try {
@@ -56,6 +68,8 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
case "terrain-create" -> createTerrain(sender);
case "terrain-check" -> checkTerrain(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()
+ " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null));
default -> throw new IllegalArgumentException("Unknown lifecycle operation");
@@ -66,6 +80,61 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
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 {
if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) {
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);
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, world.getMinHeight(), z).getType() != Material.GREEN_CONCRETE
|| world.getBiome(x, y, z) != Biome.PLAINS) {
throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z
+ ": " + world.getBlockAt(x, y, z).getType());
@@ -11,6 +11,15 @@
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
@@ -21,7 +21,7 @@
"rockPalette": {
"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.world.IrisCreator;
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.IrisEntity;
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.LivingEntity;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
@@ -38,9 +44,13 @@ import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
@@ -83,7 +93,7 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
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;
}
try {
@@ -93,6 +103,8 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
case "zombies" -> runRegion(sender, () -> spawnZombies(sender));
case "stress" -> runRegion(sender, () -> stress(sender));
case "check" -> runRegion(sender, () -> check(sender));
case "seeded" -> seeded(sender);
case "completion" -> completion(sender);
case "surface" -> view(sender, 67D);
case "cave" -> view(sender, -46D);
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) {
try {
world.setDifficulty(Difficulty.NORMAL);
world.setTime(6000L);
world.setStorm(false);
world.setThundering(false);
world.setGameRule(GameRules.SPAWN_MOBS, true);
List<CompletableFuture<Void>> chunks = new ArrayList<>();
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, () -> {
try {
chunk.addPluginChunkTicket(this);
for (Entity entity : chunk.getEntities()) {
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
entity.remove();
}
}
retained.complete(null);
} catch (Throwable 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) {
Chunk chunk = requireWorld().getChunkAt(0, 0);
RuntimeSpawn spawn = runtimeSpawn(chunk, "frogs");
getLogger().info(spawnDiagnostics(spawn, chunk));
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,
@@ -266,7 +492,7 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
require(count(chunk, EntityType.ZOMBIE) == 0, "Cave phase already ran");
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
getLogger().info(spawnDiagnostics(spawn, chunk));
invokeAmbient(spawn, chunk);
invokeAmbient(spawn, chunk, false);
firstZombieBatch = count(chunk, EntityType.ZOMBIE);
require(firstZombieBatch == ZOMBIE_CAPACITY,
"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);
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
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");
}
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();
}
private void invokeAmbient(RuntimeSpawn spawn, Chunk chunk) {
private void invokeAmbient(RuntimeSpawn spawn, Chunk chunk, boolean initial) {
ambientInvocation.set(true);
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) {
throw new IllegalStateException("Iris ambient spawning failed", failure.getCause());
} 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) {
}
}
@@ -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)
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 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')
@@ -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,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)
})
}
}
}
@@ -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());
}
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties()
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.1-26.2'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5'))
Closure<String> irisArtifactName = { String platform, String targetVersion ->
+1 -1
View File
@@ -56,7 +56,7 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties()
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.1-26.2'))
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'))
Closure<String> loaderDisplayVersion = { String loaderVersion ->
@@ -195,8 +195,6 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
if (!nativeGenerator.represents(level)) {
throw new IllegalArgumentException("NativeWorld does not use Iris generator '" + dimensionKey + "'");
}
requireGlobalStructureGeneration(
NativeModdedServer.forWorld(level).generateStructures(), dimensionKey);
Engine activeEngine = engineIfBound();
Engine replacement = ModdedWorldEngines.prepareReplacement(
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
// history verification on every chunk call and fail the same way.
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.
boundLevel = level;
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
* 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,
S sampler) {
if (isGuaranteedSurfaceBiome(quartY, engine.getMinHeight())) {
if (isGuaranteedSurfaceBiome(engine, quartY)) {
return getSurfaceStructureBiome(engine, quartX, quartZ, sampler);
}
boolean cacheable = isBiomeCacheable(
@@ -512,7 +512,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
}
boolean monumentQuery = isMonumentSurfaceBiomeQuery(
y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight());
surfaceQuery = monumentQuery || isGuaranteedSurfaceBiome(minQuartY, engine.getMinHeight());
surfaceQuery = monumentQuery || isGuaranteedSurfaceBiome(engine, minQuartY);
}
if (!surfaceQuery) {
return query.fallback().get();
@@ -701,6 +701,11 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
int blockZ = quartZ << 2;
int internalY = blockY - engine.getMinHeight();
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
? stackLayout == null
? resolveDimensionStackLayer(engine, blockX, internalY, blockZ)
@@ -846,7 +851,13 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
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 caveSwitchY = Math.max(-8 - minHeight, 40);
return internalY > caveSwitchY;
@@ -50,7 +50,7 @@ public final class ModdedDecoratorHooks implements DecoratorPlatformHooks.FaceFi
int xx = rX + dx;
int yy = y + dy;
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);
}
}
@@ -70,6 +70,7 @@ import art.arcane.iris.modded.service.ModdedEngineMaintenanceService;
import art.arcane.iris.modded.service.ModdedEntitySpawnService;
import art.arcane.iris.modded.service.ModdedLogFilterService;
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.ModdedStudioHotloadService;
import art.arcane.iris.modded.service.ModdedTreeFellerService;
@@ -477,6 +478,7 @@ public final class ModdedEngineBootstrap {
createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService());
createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService());
createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService());
createdServices.register(ModdedPackUpdateService.class, new ModdedPackUpdateService());
ModdedStudioHotloadService studioHotloadService = createdServices.register(
ModdedStudioHotloadService.class, new ModdedStudioHotloadService());
createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService());
@@ -27,7 +27,6 @@ import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* Owns the Iris generation pool and the single decision of whether this loader already runs chunk
@@ -43,7 +42,7 @@ public final class ModdedGenPool {
private static final long SHUTDOWN_DRAIN_MILLIS = 2_000L;
private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger();
private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem();
private static final AtomicReference<ExecutorService> GEN_POOL = new AtomicReference<>(createGenPool());
private static ExecutorService generationPool = createGenPool();
private ModdedGenPool() {
}
@@ -63,39 +62,32 @@ public final class ModdedGenPool {
return CHUNK_SYSTEM.description();
}
static ExecutorService pool() {
ExecutorService pool = GEN_POOL.get();
if (pool != null && !pool.isShutdown()) {
return pool;
}
start();
ExecutorService restarted = GEN_POOL.get();
if (restarted == null) {
static synchronized ExecutorService pool() {
if (generationPool.isShutdown()) {
throw new RejectedExecutionException("Iris gen pool is shut down");
}
return restarted;
return generationPool;
}
static void start() {
while (true) {
ExecutorService current = GEN_POOL.get();
if (current != null && !current.isShutdown()) {
return;
}
ExecutorService created = createGenPool();
if (GEN_POOL.compareAndSet(current, created)) {
return;
}
created.shutdownNow();
static synchronized void start() {
if (!generationPool.isShutdown()) {
return;
}
if (!generationPool.isTerminated()) {
throw new IllegalStateException("Iris gen pool cannot restart while prior workers are still active");
}
generationPool = createGenPool();
}
static void shutdown() {
ExecutorService pool = GEN_POOL.getAndSet(null);
if (pool == null) {
return;
ExecutorService pool;
synchronized (ModdedGenPool.class) {
pool = generationPool;
if (pool.isTerminated()) {
return;
}
pool.shutdown();
}
pool.shutdown();
try {
if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) {
return;
@@ -6,10 +6,9 @@ import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.configuration.IrisSettings;
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.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationEpochContractFactory;
import art.arcane.iris.world.history.GenerationHistory;
@@ -174,7 +173,7 @@ final class ModdedGenerationHistoryStorage {
static ActivePack resolveActive(GenerationHistory history) throws IOException {
GenerationEpoch epoch = history.activeEpoch();
Path packRoot = normalize(history.activePackRoot());
validatePack(packRoot, epoch.packFingerprint());
validatePack(packRoot);
validateDimension(packRoot, epoch);
return new ActivePack(
packRoot,
@@ -184,13 +183,16 @@ final class ModdedGenerationHistoryStorage {
);
}
private static void validatePack(Path packRoot, String fingerprint) {
String contextFingerprint = PackValidationCache.contextFingerprint();
PackValidationResult validation = PackValidator.validate(packRoot.toFile());
PackValidationRegistry.publish(packRoot, validation, fingerprint, contextFingerprint);
static PackValidationResult validatePack(Path packRoot) {
PackValidationResult validation = PackValidationRegistry.getMatching(
packRoot, PackFingerprints.computePackTreeFingerprint(packRoot.toFile()));
if (validation == null) {
validation = ModdedStartup.validatePack(packRoot.toFile());
}
if (!validation.isLoadable()) {
throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors());
}
return validation;
}
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.iris.spi.IrisPlatforms;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.diagnostics.splash.IrisSplashComposer;
import art.arcane.iris.diagnostics.splash.IrisSplashRenderer;
import art.arcane.iris.spi.IrisLogging;
import java.io.File;
public final class ModdedIrisSplash {
@@ -34,18 +32,11 @@ public final class ModdedIrisSplash {
}
public static void print(NativeModdedLoader loader) {
printPacks(loader);
if (isLogoEnabled()) {
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) {
String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion();
String[] splash = IrisSplashRenderer.renderPlain();
@@ -45,6 +45,9 @@ public final class ModdedMixinAudit {
private static final AtomicBoolean AUDITED = new AtomicBoolean(false);
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",
NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence",
false, NativeMixinFlags::entityPersistenceRan),
@@ -108,7 +111,7 @@ public final class ModdedMixinAudit {
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("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("===============================================================");
}
@@ -122,17 +122,6 @@ final class ModdedNativeStructurePolicy implements NativeModdedStructureStage.Po
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
public String generationLabel(String 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.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public final class ModdedPrimaryWorldRouter {
private static final int TICK_INTERVAL = 20;
private static final Set<UUID> routed = ConcurrentHashMap.newKeySet();
private static final Set<UUID> inFlight = ConcurrentHashMap.newKeySet();
private static final ConcurrentMap<UUID, CompletableFuture<Boolean>> routes = new ConcurrentHashMap<>();
private static int tickCounter = 0;
private static volatile String loginRefusal;
private static volatile String absentPrimary;
@@ -45,8 +44,12 @@ public final class ModdedPrimaryWorldRouter {
}
public static void clear() {
routed.clear();
inFlight.clear();
routes.forEach((id, route) -> {
if (routes.remove(id, route)) {
route.cancel(false);
}
});
tickCounter = 0;
loginRefusal = null;
absentPrimary = null;
}
@@ -57,8 +60,10 @@ public final class ModdedPrimaryWorldRouter {
*/
public static void forget(UUID player) {
if (player != null) {
routed.remove(player);
inFlight.remove(player);
CompletableFuture<Boolean> route = routes.remove(player);
if (route != null) {
route.cancel(false);
}
}
}
@@ -110,36 +115,52 @@ public final class ModdedPrimaryWorldRouter {
return;
}
server.forEachPlayer(player -> {
UUID id = player.id();
if (routed.contains(id) || !inFlight.add(id)) {
return;
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();
if (routes.containsKey(id)) {
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)) {
inFlight.remove(id);
routed.add(id);
return;
}
try {
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player,
new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(),
ModdedTeleportDeadline.fromNow()));
teleport.whenComplete((success, failure) -> {
inFlight.remove(id);
if (Boolean.TRUE.equals(success) && failure == null) {
routed.add(id);
return;
}
if (failure != null) {
});
if (!player.isInWorld(overworld)) {
route.complete(true);
return;
}
try {
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player,
new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(),
ModdedTeleportDeadline.fromNow()));
route.whenComplete((success, failure) -> {
if (!Boolean.TRUE.equals(success) && !teleport.isDone()) {
teleport.cancel(false);
}
});
teleport.whenComplete((success, failure) -> {
if (failure != null) {
if (route.completeExceptionally(failure)) {
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'",
id, primary, failure);
}
});
} catch (Throwable e) {
inFlight.remove(id);
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, e);
} else {
route.complete(Boolean.TRUE.equals(success));
}
});
} 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.spi.IrisServices;
import art.arcane.iris.spi.protocol.IrisProtocol;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import java.util.Objects;
import java.util.UUID;
@@ -128,6 +129,10 @@ public final class ModdedProtocolHandler {
}
String sessionId = sessionId(player);
ModdedStartup.warnStartupFailuresTo(player);
ModdedPackUpdateService packUpdates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
if (packUpdates != null) {
packUpdates.notifyPlayer(player);
}
IrisSessionRegistry current = registry;
ModdedProtocolTransport currentTransport = transport;
if (current == null || currentTransport == null) {
@@ -18,10 +18,13 @@
package art.arcane.iris.modded;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.pack.BrokenPackException;
import art.arcane.iris.pack.PackDirectoryResolver;
import art.arcane.iris.pack.PackDownloader;
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.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 java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public final class ModdedStartup {
private static final int COMPAT_BOOT_KEY_CAP = 3;
@@ -60,36 +59,9 @@ public final class ModdedStartup {
if (!PREPARED.compareAndSet(false, true)) {
return;
}
reportLegacyPacksDirectory();
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:
* 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) {
try {
PackValidationResult result = PackValidator.validate(packDir);
PackValidationRegistry.publish(result);
PackValidationResult result = validatePack(packDir);
String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null;
String compatSummary = PackValidator.compatSummary(result, minecraftVersion);
String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary;
@@ -184,16 +155,15 @@ public final class ModdedStartup {
throw new BrokenPackException(pack, List.of(
"Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + "."));
}
PackValidationResult cached = PackValidationRegistry.get(pack);
if (cached != null && cached.getValidatedAtMillis() >= newestModificationMillis(packDir.toPath())) {
// prepareForStartup already validated this pack and nothing in it changed since; re-validating per
// persistent dimension at boot costs a full pack parse each time.
return PackValidationRegistry.requireLoadable(pack);
}
Path packRoot = packDir.toPath();
try {
PackValidationResult result = PackValidator.validate(packDir);
PackValidationRegistry.publish(result);
return PackValidationRegistry.requireLoadable(pack);
PackValidationResult cached = PackValidationRegistry.getMatching(
packRoot, PackFingerprints.computePackTreeFingerprint(packDir));
if (cached != null) {
return PackValidationRegistry.requireLoadable(packRoot);
}
validatePack(packDir);
return PackValidationRegistry.requireLoadable(packRoot);
} catch (BrokenPackException e) {
throw 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) {
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
ModdedDimensionRegistryStore.loadForStartup(server);
@@ -266,23 +272,6 @@ public final class ModdedStartup {
+ 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
* 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.
*/
public static void warnStartupFailuresTo(NativeProtocolPlayer player) {
if (player == null || !player.isGameMaster()) {
if (player == null || (!player.isGameMaster() && !player.isServerOwner())) {
return;
}
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.world.entity.IrisSpawnGroup;
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.spi.IrisLogging;
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 java.util.Map;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.ArrayList;
import java.util.Optional;
import java.util.Set;
import java.util.Comparator;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
@@ -235,6 +239,10 @@ public final class ModdedWorldManager implements EngineWorldManager {
}
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)) {
return false;
}
@@ -249,54 +257,25 @@ public final class ModdedWorldManager implements EngineWorldManager {
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try {
List<PreparedMarkerSpawn> markers = markerSystemEnabled()
? prepareMarkerSpawns(level, chunkX, chunkZ, chunk) : List.of();
? prepareMarkerSpawns(level, chunkX, chunkZ, chunk, true) : List.of();
Optional<BiomeEnvironment> environment = ambientSystemEnabled()
? resolveSurfaceEnvironment(chunkX, chunkZ) : Optional.empty();
chunk.raiseFlagUnchecked(INITIAL_SPAWN_COMPLETION_FLAG, () -> {
spawnPreparedMarkers(level, markers, true);
if (environment.isPresent()) {
scheduleInitialFollowUp(level, chunkX, chunkZ, environment.get());
if (ambientSystemEnabled() && engine.getComplex() == null) {
return false;
}
spawnPreparedMarkers(level, markers, true);
if (environment.isPresent()) {
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment.get())) {
spawnAmbient(level, chunkX, chunkZ, true, environment.get());
}
});
}
chunk.raiseFlagUnchecked(INITIAL_SPAWN_COMPLETION_FLAG, () -> {});
} finally {
chunk.release();
}
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) {
if (closed || !mantleWarmups.add(key)) {
return;
@@ -380,11 +359,14 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (!mantle.isChunkLoaded(chunkX, chunkZ)) {
return;
}
if (!mantle.hasFlag(chunkX, chunkZ, INITIAL_SPAWN_COMPLETION_FLAG)) {
return;
}
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try {
if (markerSystemEnabled()) {
spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk), false);
spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk, false), false);
}
if (ambientSystemEnabled()) {
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();
KList<IrisPosition> obstructed = new KList<>();
List<PreparedMarkerSpawn> prepared = new ArrayList<>();
@@ -429,29 +411,41 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (spawners.isEmpty()) {
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) {
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();
for (IrisPosition position : obstructed) {
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);
}
private KList<IrisSpawner> resolveMarkerSpawners(IrisMarker marker, BiomeEnvironment environment) {
KList<IrisSpawner> spawners = new KList<>();
Set<IrisSpawner> seen = new HashSet<>();
for (String key : marker.getSpawners()) {
IrisSpawner spawner = environment.data().getSpawnerLoader().load(key);
if (spawner == null) {
IrisLogging.error("Cannot load spawner: " + key + " for marker on " + engine.getName());
continue;
}
spawner.setReferenceMarker(marker);
spawners.add(spawner);
if (!spawner.isCompatExcluded() && seen.add(spawner)) {
spawners.add(spawner);
}
}
return spawners;
}
@@ -459,32 +453,29 @@ public final class ModdedWorldManager implements EngineWorldManager {
private void spawnPreparedMarkers(NativeWorld level, List<PreparedMarkerSpawn> markers, boolean initial) {
for (PreparedMarkerSpawn marker : markers) {
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) {
KList<IrisEntitySpawn> spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns();
private void spawnFromSpawner(NativeWorld level, IrisPosition position, RNG rng, SpawnContext context) {
IrisSpawner spawner = context.spawner();
KList<IrisEntitySpawn> spawns = context.initial() ? spawner.getInitialSpawns() : spawner.getSpawns();
if (spawns.isEmpty()) {
return;
}
for (IrisEntitySpawn entry : spawns) {
entry.setReferenceSpawner(spawner);
entry.setReferenceMarker(spawner.getReferenceMarker());
}
IrisEntitySpawn chosen = rarityPick(spawns);
IrisEntitySpawn chosen = rarityPick(spawns, rng);
if (chosen == null) {
return;
}
int chunkX = position.getX() >> 4;
int chunkZ = position.getZ() >> 4;
if (!canSpawn(spawner, chunkX, chunkZ)) {
if (!canSpawn(spawner, chunkX, chunkZ, context.initial())) {
return;
}
int spawned = spawnEntryAt(level, chosen, spawner, position);
if (spawned > 0) {
int spawned = spawnEntryAt(level, chosen, position, rng, context);
if (spawned > 0 && !context.initial()) {
spawner.spawn(engine, chunkX, chunkZ);
}
}
@@ -521,59 +512,168 @@ public final class ModdedWorldManager implements EngineWorldManager {
}
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<>();
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial);
KList<SpawnCandidate> pool = new KList<>();
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, false);
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()) {
return;
}
IrisEntitySpawn chosen = rarityPick(pool);
if (chosen == null || chosen.getReferenceSpawner() == null) {
SpawnCandidate chosen = rarityPick(pool, rng);
if (chosen == null) {
return;
}
IrisSpawner spawner = chosen.getReferenceSpawner();
if (!canSpawn(spawner, chunkX, chunkZ)) {
IrisSpawner spawner = chosen.spawner();
if (!canSpawn(spawner, chunkX, chunkZ, initial)) {
return;
}
int spawned = spawnEntry(level, chosen, spawner, chunkX, chunkZ,
remainingCapacity(chosen, spawner, chunkMobs));
if (spawned > 0) {
int spawned = spawnEntry(level, chosen.entry(), chunkX, chunkZ,
remainingCapacity(chosen.entry(), spawner, chunkMobs), rng, new SpawnContext(spawner, null, initial));
recordSpawned(chosen.entry(), chunkMobs, spawned);
if (spawned > 0 && !initial) {
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) {
if (spawner == null) {
if (spawner == null || (spawner.getGroup() == IrisSpawnGroup.CAVE) != underground) {
continue;
}
if (biomeFilter != null && !spawner.isValid(biomeFilter)) {
continue;
}
if (!canSpawn(spawner, chunkX, chunkZ)) {
if (!canSpawn(spawner, chunkX, chunkZ, initial)) {
continue;
}
KList<IrisEntitySpawn> spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns();
for (IrisEntitySpawn entry : spawns) {
entry.setReferenceSpawner(spawner);
entry.setReferenceMarker(spawner.getReferenceMarker());
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) {
IrisEntity entity = entry.getRealEntity(engine);
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);
if (irisEntity == null) {
return 0;
@@ -581,18 +681,24 @@ public final class ModdedWorldManager implements EngineWorldManager {
int min = entry.getMinSpawns();
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) {
return 0;
}
RNG entityRng = entry.getRng().aquire(() -> new RNG(engine.getSeedManager().getEntity()));
IrisSpawner spawner = context.spawner();
IrisSpawnGroup group = spawner.getGroup();
KList<IrisPosition> caveFloors = group == IrisSpawnGroup.CAVE
? engine.getMantle().findMarkers(chunkX, chunkZ, MarkerMatter.CAVE_FLOOR)
: new KList<>();
if (context.initial()) {
caveFloors.sort(Comparator.comparingInt(IrisPosition::getX)
.thenComparingInt(IrisPosition::getY).thenComparingInt(IrisPosition::getZ));
}
int spawned = 0;
for (int i = 0; i < count; i++) {
RNG entityRng = EntitySpawnSeed.entity(rng.getSeed(), i);
RNG positionRng = entityRng.nextParallelRNG(0x632BE59BD9B4E019L);
int worldX;
int worldY;
int worldZ;
@@ -600,16 +706,16 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (caveFloors.isEmpty()) {
continue;
}
IrisPosition caveFloor = caveFloors.getRandom(RNG.r);
IrisPosition caveFloor = caveFloors.getRandom(positionRng);
worldX = caveFloor.getX();
worldY = caveFloor.getY();
worldZ = caveFloor.getZ();
} else {
worldX = (chunkX << 4) + RNG.r.i(16);
worldZ = (chunkZ << 4) + RNG.r.i(16);
worldX = (chunkX << 4) + positionRng.i(16);
worldZ = (chunkZ << 4) + positionRng.i(16);
int surfaceY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, false);
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) {
continue;
}
@@ -619,7 +725,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
continue;
}
// 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;
}
if (!surfaceMatches(irisEntity.getSurface(), level, worldX, worldY, worldZ)) {
@@ -635,7 +741,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
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);
if (irisEntity == null) {
return 0;
@@ -643,21 +749,21 @@ public final class ModdedWorldManager implements EngineWorldManager {
int min = entry.getMinSpawns();
int max = entry.getMaxSpawns();
int count = LootResolver.inclusive(RNG.r, min, max);
int count = LootResolver.inclusive(rng, min, max);
if (count <= 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 worldY = position.getY() + 1;
int worldZ = position.getZ();
int spawned = 0;
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).
if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) {
if (!context.initial() && !lightAllowed(context.spawner(), level, worldX, worldY, worldZ)) {
continue;
}
if (irisEntity.getSurface().isFluid()
@@ -672,16 +778,15 @@ public final class ModdedWorldManager implements EngineWorldManager {
return spawned;
}
private void exhaustMarker(IrisSpawner spawner, IrisPosition position) {
IrisMarker marker = spawner.getReferenceMarker();
if (marker == null || !marker.shouldExhaust()) {
private void exhaustMarker(IrisMarker marker, IrisPosition position, RNG rng) {
if (marker == null || !marker.shouldExhaust(rng)) {
return;
}
engine.getMantle().getMantle().remove(position.getX(), position.getY() - engine.getWorld().minHeight(), position.getZ(), MatterMarker.class);
}
private boolean canSpawn(IrisSpawner spawner, int chunkX, int chunkZ) {
return spawner.canSpawn(engine, chunkX, chunkZ);
private boolean canSpawn(IrisSpawner spawner, int chunkX, int chunkZ, boolean initial) {
return !spawner.isCompatExcluded() && (initial || spawner.canSpawn(engine, chunkX, chunkZ));
}
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;
}
private IrisEntitySpawn rarityPick(KList<IrisEntitySpawn> entries) {
KList<IrisEntitySpawn> weighted = Rarity.expandWeighted(entries);
return weighted.isEmpty() ? entries.getRandom() : weighted.getRandom();
private static <T extends Rarity> T rarityPick(KList<T> entries, RNG rng) {
KList<T> weighted = Rarity.expandWeighted(entries);
return weighted.isEmpty() ? entries.getRandom(rng) : weighted.getRandom(rng);
}
private static long pack(int x, int z) {
@@ -893,7 +998,14 @@ public final class ModdedWorldManager implements EngineWorldManager {
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) {
@@ -33,6 +33,7 @@ import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedDimensionManager;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import art.arcane.iris.modded.ModdedForcedDatapack;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.iris.modded.ModdedScheduler;
@@ -445,6 +446,12 @@ public final class IrisModdedCommands {
);
String completionMessage = downloadCompletionMessage(result);
if (result != null) {
if (result.changed()) {
ModdedPackUpdateService updates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
if (updates != null) {
dispatchDownloadFeedback(source, updates::refresh);
}
}
if (completionMessage != null) {
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.structure.placement.IrisStructureLocator;
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.placement.StructureReachability;
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)));
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;
}
@@ -545,12 +548,12 @@ final class ModdedLocateCommands {
previous.cancel(true);
}
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,
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) {
return;
}
@@ -576,15 +579,77 @@ final class ModdedLocateCommands {
});
return;
}
if (locator instanceof BiomeLocator caveLocator) {
prepareCaveLocate(new CaveLocateRequest(source, level, engine, player, label, server, playerId,
search, caveLocator, at));
return;
}
server.execute(() -> {
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,
NativeProtocolPlayer player, String label, Position2 at) {
private static void prepareCaveLocate(CaveLocateRequest request) {
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
// two minutes, and the captured NativeProtocolPlayer may be gone or elsewhere by then.
if (!player.connected()) {
@@ -600,6 +665,17 @@ final class ModdedLocateCommands {
try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_locator_teleport");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
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);
if (!teleported) {
IrisModdedCommands.fail(source, IrisLanguage.plain(
@@ -611,7 +687,8 @@ final class ModdedLocateCommands {
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)));
} 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)));
}
}
@@ -635,6 +712,14 @@ final class ModdedLocateCommands {
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,
NativeStructureAvailability availability) {
}
@@ -18,6 +18,8 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.modded.ModdedStartup;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.pack.validation.PackCompatReport;
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.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.ModdedEngineBootstrap;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
@@ -138,8 +139,7 @@ public final class ModdedPackCommands {
int broken = 0;
for (File target : targets) {
try {
PackValidationResult result = PackValidator.validate(target);
PackValidationRegistry.publish(result);
PackValidationResult result = ModdedStartup.validatePack(target);
if (!result.isLoadable()) {
broken++;
}
@@ -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.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
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 CHECK_LATCH_MILLIS = 1_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 volatile ExecutorService executor;
@@ -81,8 +84,12 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override
public void onEnable() {
if (executor != null) {
return;
ExecutorService current = executor;
if (current != null && !current.isTerminated()) {
if (!current.isShutdown()) {
return;
}
throw new IllegalStateException("Iris Studio hotload cannot restart while prior workers are still active");
}
executor = Executors.newSingleThreadExecutor((Runnable task) -> {
Thread thread = new Thread(task, "Iris Studio Hotload");
@@ -96,10 +103,10 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override
public void onDisable() {
ExecutorService active = executor;
executor = null;
if (active != null) {
active.shutdownNow();
if (!shutdownAndDrain(active)) {
throw new IllegalStateException("Iris Studio hotload worker remained active during service shutdown");
}
executor = null;
for (Watch watch : watches.values()) {
watch.close();
}
@@ -197,7 +204,7 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override
public void onServerTick(NativeModdedServer server) {
ExecutorService active = executor;
if (active == null) {
if (active == null || active.isShutdown()) {
return;
}
long now = System.currentTimeMillis();
@@ -284,6 +291,30 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
&& !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) {
if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) {
return true;
@@ -38,12 +38,18 @@ public class IrisModdedStructureParityTest {
Bootstrap.bootStrap();
}
private static Engine surfaceEngine(int minHeight) {
Engine engine = mock(Engine.class);
when(engine.getMinHeight()).thenReturn(minHeight);
return engine;
}
@Test
public void surfaceBiomeFastPathBeginsAboveTheCaveSwitch() {
assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(-2, -256));
assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(-1, -256));
assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(10, 0));
assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(11, 0));
assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(-256), -2));
assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(-256), -1));
assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(0), 10));
assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(0), 11));
}
@Test
@@ -57,8 +57,11 @@ public class ModdedBindFailureTest {
when(server.generateStructures()).thenReturn(false);
try (MockedStatic<NativeModdedServer> servers = mockStatic(NativeModdedServer.class);
MockedStatic<ModdedWorldEngines> engines = mockStatic(ModdedWorldEngines.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
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 second = assertThrows(IllegalStateException.class, generator::engine);
@@ -66,9 +69,9 @@ public class ModdedBindFailureTest {
assertSame(recorded, second.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: ")
&& 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<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
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));
generator.unbindEngine(level);
IllegalStateException second = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
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));
}
}
@@ -2,6 +2,11 @@ package art.arcane.iris.modded;
import art.arcane.iris.generation.biome.IrisBiome;
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.GenerationSessionLease;
import art.arcane.iris.generation.runtime.IrisComplex;
@@ -11,20 +16,54 @@ import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import org.junit.Test;
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.Set;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
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
public void registryOrderAndHolderIdentitySurviveThePolicyBoundaryAndRefreshOnRepoint() {
BiomeToken ocean = new BiomeToken("minecraft:ocean");
@@ -39,6 +39,37 @@ public class ModdedClientPackageIsolationTest {
"art/arcane/iris/nativegen");
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
public void serverSidePackagesNeverReferenceClientOnlyTypes() throws IOException, URISyntaxException {
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);
}
}
@@ -0,0 +1,78 @@
package art.arcane.iris.modded;
import org.junit.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class ModdedGenPoolTest {
@Test
public void executorLookupDoesNotRestartStoppedPool() throws Exception {
ModdedGenPool.start();
ExecutorService original = ModdedGenPool.pool();
ModdedGenPool.shutdown();
try {
ModdedGenPool.pool();
fail("Stopped generation pool must reject executor lookup");
} catch (RejectedExecutionException expected) {
assertTrue(original.isShutdown());
}
ModdedGenPool.start();
try {
assertNotSame(original, ModdedGenPool.pool());
} finally {
ModdedGenPool.shutdown();
}
}
@Test
public void restartWaitsForPreviousWorkersToTerminate() throws Exception {
ModdedGenPool.start();
ExecutorService original = ModdedGenPool.pool();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
original.execute(() -> {
entered.countDown();
boolean released = false;
while (!released) {
try {
release.await();
released = true;
} catch (InterruptedException ignored) {
Thread.interrupted();
}
}
});
assertTrue(entered.await(5L, TimeUnit.SECONDS));
try {
Thread.currentThread().interrupt();
ModdedGenPool.shutdown();
assertTrue(Thread.interrupted());
assertFalse(original.isTerminated());
try {
ModdedGenPool.start();
fail("Generation pool must retain workers until they terminate");
} catch (IllegalStateException expected) {
assertFalse(original.isTerminated());
}
} finally {
Thread.interrupted();
release.countDown();
assertTrue(original.awaitTermination(5L, TimeUnit.SECONDS));
ModdedGenPool.start();
assertNotSame(original, ModdedGenPool.pool());
ExecutorService restarted = ModdedGenPool.pool();
ModdedGenPool.start();
assertSame(restarted, ModdedGenPool.pool());
ModdedGenPool.shutdown();
}
}
}
@@ -0,0 +1,345 @@
package art.arcane.iris.modded;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.SeedManager;
import art.arcane.iris.generation.terrain.InferredType;
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.pack.loading.ResourceLoader;
import art.arcane.iris.pack.validation.CompatStatus;
import art.arcane.iris.pack.value.IrisRange;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.world.entity.IrisSpawnGroup;
import art.arcane.iris.world.entity.IrisMarker;
import art.arcane.iris.world.entity.EntitySpawnSeed;
import art.arcane.iris.structure.placement.LootResolver;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnQueries;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnedEntity;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.math.Rarity;
import art.arcane.volmlib.util.function.Consumer4;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterMarker;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.ServerLevel;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.lang.reflect.Method;
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.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedInitialEntitySpawnTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void initialBatchesIgnoreChunkOrderRuntimeRandomPopulationLightAndCooldowns() {
Map<Long, List<Spawned>> first;
try (Fixture fixture = new Fixture()) {
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
RNG.r.nextLong();
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, 4, -5));
first = fixture.byChunk();
fixture.verifyInitialPolicy();
}
try (Fixture fixture = new Fixture()) {
for (int i = 0; i < 64; i++) {
RNG.r.nextLong();
}
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, 4, -5));
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
assertEquals(first, fixture.byChunk());
assertEquals(2, fixture.completed.size());
for (List<Spawned> batch : fixture.byChunk().values()) {
assertTrue(batch.size() >= 2 && batch.size() <= 3);
}
fixture.verifyInitialPolicy();
}
}
@Test
public void undergroundAndSurfacePassesShareTheSuccessfulPopulationBudget() {
try (Fixture fixture = new Fixture()) {
BiomeEnvironment environment = fixture.engine.getSurfaceBiomeEnvironment(0, 0);
when(environment.dimension().hasUndergroundSpawners(fixture.engine)).thenReturn(true);
when(fixture.engine.getBiomeOrMantleEnvironment(anyInt(), anyInt(), anyInt())).thenReturn(environment);
NativeBlockState stone = mock(NativeBlockState.class);
NativeBlockState air = mock(NativeBlockState.class);
when(air.isAir()).thenReturn(true);
when(fixture.world.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
int y = invocation.getArgument(1);
return y <= -21 || y == 50 ? stone : air;
});
fixture.queries.when(() -> NativeSpawnQueries.solid(stone)).thenReturn(true);
IrisEntity entity = new IrisEntity().setType("minecraft:cow");
IrisEntitySpawn entry = spy(new IrisEntitySpawn().setMinSpawns(2).setMaxSpawns(2));
doReturn(entity).when(entry).getRealEntity(fixture.engine);
fixture.definitions.add(new IrisSpawner().setGroup(IrisSpawnGroup.CAVE)
.setInitialSpawns(new KList<>(entry)).setMaxEntitiesPerChunk(3));
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
assertEquals(3, fixture.spawned.size());
assertEquals(2, fixture.spawned.stream().filter(spawn -> spawn.y() == -20).count());
assertEquals(1, fixture.spawned.stream().filter(spawn -> spawn.y() == 51).count());
fixture.queries.verify(() -> NativeSpawnQueries.livingEntityCategories(eq(fixture.world), anyInt(), anyInt()), never());
}
}
@Test
public void initialCompletionFollowsOwnedSpawnPassAndDoesNotRepeatIt() {
try (Fixture fixture = new Fixture()) {
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
int count = fixture.spawned.size();
assertTrue(count > 0);
assertEquals("complete", fixture.trace.getLast());
assertTrue(fixture.trace.subList(0, fixture.trace.size() - 1).stream().allMatch("spawn"::equals));
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
assertEquals(count, fixture.spawned.size());
assertEquals(1, fixture.completed.size());
}
}
@Test
public void unsafeChunkAndDisabledMobSpawningLeaveCompletionForRetry() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> NativeSpawnQueries.ambientAllowed(fixture.world, -2, 3, true)).thenReturn(false);
assertFalse(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
assertTrue(fixture.completed.isEmpty());
fixture.queries.when(() -> NativeSpawnQueries.ambientAllowed(fixture.world, -2, 3, true)).thenReturn(true);
fixture.entities.when(() -> ModdedEntitySpawner.chunksSafe(any(NativeEntityRuntime.class), eq(-2), eq(3)))
.thenReturn(false);
assertFalse(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
assertTrue(fixture.completed.isEmpty());
assertTrue(fixture.spawned.isEmpty());
fixture.entities.when(() -> ModdedEntitySpawner.chunksSafe(any(NativeEntityRuntime.class), eq(-2), eq(3)))
.thenReturn(true);
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
assertTrue(fixture.completed.contains(key(-2, 3)));
}
}
@Test
@SuppressWarnings("unchecked")
public void initialMarkerSelectionUsesOneStreamAndExcludesDuplicateAndUnsupportedSpawners() throws ReflectiveOperationException {
try (Fixture fixture = new Fixture()) {
IrisSpawner first = fixture.definitions.get(0);
IrisSpawner second = fixture.definitions.get(1);
first.setLoadKey("a");
second.setLoadKey("b");
IrisSpawner excluded = new IrisSpawner();
excluded.setLoadKey("excluded");
excluded.setCompat(CompatStatus.excludedBy(List.of()));
IrisMarker marker = new IrisMarker().setSpawners(new KList<>("b", "excluded", "a", "b"))
.setEmptyAbove(false).setExhaustionChance(0.5);
BiomeEnvironment environment = fixture.engine.getSurfaceBiomeEnvironment(0, 0);
ResourceLoader<IrisSpawner> spawners = environment.data().getSpawnerLoader();
when(spawners.load("a")).thenReturn(first);
when(spawners.load("b")).thenReturn(second);
when(spawners.load("excluded")).thenReturn(excluded);
ResourceLoader<IrisMarker> markers = mock(ResourceLoader.class);
when(environment.data().getMarkerLoader()).thenReturn(markers);
when(markers.load("test")).thenReturn(marker);
when(fixture.engine.getWorld().minHeight()).thenReturn(-64);
when(fixture.engine.getBiomeEnvironment(-25, 15, 57)).thenReturn(environment);
MantleChunk<Matter> chunk = mock(MantleChunk.class);
doAnswer(invocation -> {
Consumer4<Integer, Integer, Integer, MatterMarker> callback = invocation.getArgument(1);
callback.accept(7, 15, 9, new MatterMarker("test"));
return null;
}).when(chunk).iterate(eq(MatterMarker.class), any());
Method resolve = ModdedWorldManager.class.getDeclaredMethod("resolveMarkerSpawners", IrisMarker.class, BiomeEnvironment.class);
resolve.setAccessible(true);
KList<IrisSpawner> resolved = (KList<IrisSpawner>) resolve.invoke(fixture.manager, marker, environment);
assertEquals(List.of(second, first), resolved);
Method prepare = ModdedWorldManager.class.getDeclaredMethod("prepareMarkerSpawns", NativeWorld.class,
int.class, int.class, MantleChunk.class, boolean.class);
prepare.setAccessible(true);
Object prepared = prepare.invoke(fixture.manager, fixture.world, -2, 3, chunk, true);
Method spawn = ModdedWorldManager.class.getDeclaredMethod("spawnPreparedMarkers", NativeWorld.class, List.class, boolean.class);
spawn.setAccessible(true);
spawn.invoke(fixture.manager, fixture.world, prepared, true);
RNG rng = EntitySpawnSeed.marker(7721L, -25, -49, 57);
IrisSpawner selected = new KList<>(first, second).getRandom(rng);
IrisEntitySpawn entry = Rarity.expandWeighted(selected.getInitialSpawns()).getRandom(rng);
int count = LootResolver.inclusive(rng, entry.getMinSpawns(), entry.getMaxSpawns());
List<Spawned> expected = new ArrayList<>();
for (int ordinal = 0; ordinal < count; ordinal++) {
expected.add(new Spawned(entry.getRealEntity(fixture.engine).getType(), -25, -48, 57,
EntitySpawnSeed.entity(rng.getSeed(), ordinal).nextLong()));
}
assertEquals(expected, fixture.spawned);
Mantle<Matter> mantle = fixture.engine.getMantle().getMantle();
if (marker.shouldExhaust(EntitySpawnSeed.entity(rng.getSeed(), -1))) {
verify(mantle).remove(-25, 15, 57, MatterMarker.class);
} else {
verify(mantle, never()).remove(anyInt(), anyInt(), anyInt(), eq(MatterMarker.class));
}
}
}
private static long key(int x, int z) {
return (long) x & 0xFFFFFFFFL | (long) z << 32;
}
private record Spawned(String type, int x, int y, int z, long customization) {
}
private static final class Fixture implements AutoCloseable {
private final Engine engine = mock(Engine.class);
private final NativeWorld world = mock(NativeWorld.class);
private final ModdedWorldManager manager = new ModdedWorldManager(engine);
private final MockedStatic<IrisSettings> configured = mockStatic(IrisSettings.class);
private final MockedStatic<NativeSpawnQueries> queries = mockStatic(NativeSpawnQueries.class);
private final MockedStatic<ModdedEntitySpawner> entities = mockStatic(ModdedEntitySpawner.class);
private final List<Spawned> spawned = new ArrayList<>();
private final List<String> trace = new ArrayList<>();
private final Set<Long> completed = new HashSet<>();
private final List<IrisSpawner> definitions = new ArrayList<>();
@SuppressWarnings("unchecked")
private Fixture() {
IrisSettings settings = new IrisSettings();
settings.getWorld().setAmbientEntitySpawningSystem(true);
settings.getWorld().setMarkerEntitySpawningSystem(false);
configured.when(IrisSettings::get).thenReturn(settings);
EngineMantle engineMantle = mock(EngineMantle.class);
Mantle<Matter> mantle = mock(Mantle.class);
IrisWorld irisWorld = mock(IrisWorld.class);
SeedManager seeds = mock(SeedManager.class);
when(seeds.getEntity()).thenReturn(7721L);
when(engine.getSeedManager()).thenReturn(seeds);
when(engine.getWorld()).thenReturn(irisWorld);
when(engine.getComplex()).thenReturn(mock(IrisComplex.class));
when(engine.getMantle()).thenReturn(engineMantle);
when(engineMantle.getMantle()).thenReturn(mantle);
when(world.nativeHandle()).thenReturn(mock(ServerLevel.class));
when(world.minHeight()).thenReturn(-64);
when(world.maxHeight()).thenReturn(320);
NativeBlockState support = mock(NativeBlockState.class);
when(world.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(support);
queries.when(() -> NativeSpawnQueries.solid(support)).thenReturn(true);
queries.when(() -> NativeSpawnQueries.ambientAllowed(eq(world), anyInt(), anyInt(), anyBoolean())).thenReturn(true);
queries.when(() -> NativeSpawnQueries.surfaceHeight(eq(world), anyInt(), anyInt(), anyBoolean())).thenReturn(50);
queries.when(() -> NativeSpawnQueries.livingEntityCategories(eq(world), anyInt(), anyInt()))
.thenReturn(Map.of("creature", 1000));
queries.when(() -> NativeSpawnQueries.light(eq(world), anyInt(), anyInt(), anyInt())).thenReturn(15);
entities.when(() -> ModdedEntitySpawner.chunksSafe(any(NativeEntityRuntime.class), anyInt(), anyInt())).thenReturn(true);
entities.when(() -> ModdedEntitySpawner.isAreaClearForSpawn(any(NativeEntityRuntime.class),
any(IrisEntity.class), anyInt(), anyInt(), anyInt())).thenReturn(true);
entities.when(() -> ModdedEntitySpawner.spawn(eq(engine), any(IrisEntity.class), any(NativeEntityRuntime.class),
anyInt(), anyInt(), anyInt(), any(RNG.class))).thenAnswer(invocation -> {
IrisEntity entity = invocation.getArgument(1);
RNG rng = invocation.getArgument(6);
spawned.add(new Spawned(entity.getType(), invocation.getArgument(3), invocation.getArgument(4),
invocation.getArgument(5), rng.nextLong()));
trace.add("spawn");
return mock(NativeSpawnedEntity.class);
});
when(mantle.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
when(mantle.hasFlag(anyInt(), anyInt(), eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG)))
.thenAnswer(invocation -> completed.contains(key(invocation.getArgument(0), invocation.getArgument(1))));
when(mantle.useChunk(anyInt(), anyInt())).thenAnswer(invocation -> {
long key = key(invocation.getArgument(0), invocation.getArgument(1));
MantleChunk<Matter> chunk = mock(MantleChunk.class);
doAnswer(flag -> {
completed.add(key);
trace.add("complete");
return null;
}).when(chunk).raiseFlagUnchecked(eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG), any());
return chunk;
});
IrisData data = mock(IrisData.class);
ResourceLoader<IrisSpawner> loader = mock(ResourceLoader.class);
when(data.getSpawnerLoader()).thenReturn(loader);
IrisBiome biome = mock(IrisBiome.class);
when(biome.getInferredType()).thenReturn(InferredType.LAND);
when(biome.getEntitySpawners()).thenReturn(new KList<>());
IrisRegion region = mock(IrisRegion.class);
when(region.getEntitySpawners()).thenReturn(new KList<>());
IrisDimension dimension = mock(IrisDimension.class);
when(dimension.getEntitySpawners()).thenReturn(new KList<>("land"));
for (String type : List.of("minecraft:cow", "minecraft:pig")) {
IrisEntity entity = new IrisEntity().setType(type);
IrisEntitySpawn entry = spy(new IrisEntitySpawn().setMinSpawns(2).setMaxSpawns(5));
doReturn(entity).when(entry).getRealEntity(engine);
IrisSpawner spawner = spy(new IrisSpawner().setInitialSpawns(new KList<>(entry))
.setMaxEntitiesPerChunk(3).setAllowedLightLevels(new IrisRange(0, 0)));
doReturn(false).when(spawner).canSpawn(eq(engine), anyInt(), anyInt());
definitions.add(spawner);
}
when(loader.loadAll(any(KList.class))).thenAnswer(invocation -> {
KList<String> names = invocation.getArgument(0);
return names.contains("land") ? new KList<>(definitions) : new KList<>();
});
BiomeEnvironment environment = new BiomeEnvironment(1L, biome, region, dimension, data);
when(engine.getSurfaceBiomeEnvironment(anyInt(), anyInt())).thenReturn(environment);
when(engine.openBiomeEnvironmentScope(environment)).thenReturn(mock(BiomeEnvironment.Scope.class));
}
private Map<Long, List<Spawned>> byChunk() {
Map<Long, List<Spawned>> output = new HashMap<>();
for (Spawned spawn : spawned) {
output.computeIfAbsent(key(spawn.x() >> 4, spawn.z() >> 4), ignored -> new ArrayList<>()).add(spawn);
}
return output;
}
private void verifyInitialPolicy() {
queries.verify(() -> NativeSpawnQueries.livingEntityCategories(eq(world), anyInt(), anyInt()), never());
queries.verify(() -> NativeSpawnQueries.light(eq(world), anyInt(), anyInt(), anyInt()), never());
for (IrisSpawner spawner : definitions) {
verify(spawner, never()).canSpawn(eq(engine), anyInt(), anyInt());
verify(spawner, never()).spawn(eq(engine), anyInt(), anyInt());
}
}
@Override
public void close() {
manager.close();
entities.close();
queries.close();
configured.close();
}
}
}
@@ -0,0 +1,195 @@
package art.arcane.iris.modded;
import art.arcane.iris.modded.command.ModdedPackCommands;
import art.arcane.iris.pack.BrokenPackException;
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 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.nio.file.attribute.FileTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times;
public class ModdedPackValidationPublicationTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@After
public void clearValidation() {
PackValidationRegistry.clear();
}
@Test
public void repeatedSnapshotValidationParsesUnchangedPackOnce() throws Exception {
File root = temporaryFolder.newFolder("snapshot");
PackValidationResult result = loadable(root);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(root)).thenReturn(result);
assertSame(result, ModdedGenerationHistoryStorage.validatePack(root.toPath()));
assertSame(result, ModdedGenerationHistoryStorage.validatePack(root.toPath()));
validator.verify(() -> PackValidator.validate(root), times(1));
}
}
@Test
public void snapshotValidationRejectsChangedBytesWithPreservedTimestamp() throws Exception {
File root = temporaryFolder.newFolder("snapshot");
File content = new File(root, "content.json");
Files.writeString(content.toPath(), "valid");
FileTime timestamp = Files.getLastModifiedTime(content.toPath());
PackValidationResult broken = new PackValidationResult(root.getName(), List.of("invalid"), List.of(), 1L);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(root)).thenReturn(loadable(root), broken);
ModdedGenerationHistoryStorage.validatePack(root.toPath());
Files.writeString(content.toPath(), "wrong");
Files.setLastModifiedTime(content.toPath(), timestamp);
assertThrows(BrokenPackException.class,
() -> ModdedGenerationHistoryStorage.validatePack(root.toPath()));
assertThrows(BrokenPackException.class,
() -> ModdedGenerationHistoryStorage.validatePack(root.toPath()));
validator.verify(() -> PackValidator.validate(root), times(2));
}
}
@Test
public void snapshotValidationReparsesChangedRegistryContext() throws Exception {
File root = temporaryFolder.newFolder("snapshot");
AtomicReference<String> context = new AtomicReference<>("first");
PackValidationResult first = loadable(root);
PackValidationResult second = loadable(root);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<PackValidationCache> cache = mockStatic(PackValidationCache.class)) {
cache.when(PackValidationCache::contextFingerprint).thenAnswer(ignored -> context.get());
validator.when(() -> PackValidator.validate(root)).thenReturn(first, second);
assertSame(first, ModdedGenerationHistoryStorage.validatePack(root.toPath()));
context.set("second");
assertSame(second, ModdedGenerationHistoryStorage.validatePack(root.toPath()));
validator.verify(() -> PackValidator.validate(root), times(2));
}
}
@Test
public void validationPublishesTheCanonicalRootAndPackName() throws Exception {
File root = temporaryFolder.newFolder("terrain");
File equivalent = new File(root, ".");
PackValidationResult result = loadable(root);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(equivalent)).thenReturn(result);
assertSame(result, ModdedStartup.validatePack(equivalent));
assertSame(result, PackValidationRegistry.requireLoadable(root.toPath()));
assertSame(result, PackValidationRegistry.requireLoadable(root.getName()));
}
}
@Test
public void rootInvalidationCannotReusePackNameValidation() throws Exception {
File root = temporaryFolder.newFolder("terrain");
PackValidationRegistry.publish(loadable(root));
PackValidationResult replacement = loadable(root);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<ModdedPackCommands> commands = mockStatic(ModdedPackCommands.class)) {
commands.when(ModdedPackCommands::packsRoot).thenReturn(root.getParentFile());
validator.when(() -> PackValidator.validate(root)).thenReturn(replacement);
assertSame(replacement, ModdedStartup.requirePackForWorldCreation(root.getName()));
validator.verify(() -> PackValidator.validate(root));
assertSame(replacement, PackValidationRegistry.requireLoadable(root.toPath()));
}
}
@Test
public void changedContentWithPreservedTimestampCannotReuseValidation() throws Exception {
File root = temporaryFolder.newFolder("terrain");
File content = new File(root, "dimension.json");
Files.writeString(content.toPath(), "valid");
PackValidationResult original = loadable(root);
PackValidationResult broken = new PackValidationResult(root.getName(), List.of("invalid"), List.of(),
System.currentTimeMillis());
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<ModdedPackCommands> commands = mockStatic(ModdedPackCommands.class)) {
commands.when(ModdedPackCommands::packsRoot).thenReturn(root.getParentFile());
validator.when(() -> PackValidator.validate(root)).thenReturn(original, broken);
ModdedStartup.validatePack(root);
Files.writeString(content.toPath(), "wrong");
Files.setLastModifiedTime(content.toPath(), FileTime.fromMillis(1L));
Files.setLastModifiedTime(root.toPath(), FileTime.fromMillis(1L));
assertThrows(BrokenPackException.class,
() -> ModdedStartup.requirePackForWorldCreation(root.getName()));
}
}
@Test
public void externalEditDuringValidationCannotPublishMixedSnapshot() 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)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
Files.writeString(content.toPath(), "changed");
return loadable(root);
});
assertThrows(BrokenPackException.class, () -> ModdedStartup.validatePack(root));
assertNull(PackValidationRegistry.get(root.toPath()));
}
}
@Test
public void mutationDuringValidationPreventsStalePublication() throws Exception {
File root = temporaryFolder.newFolder("terrain");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(root.toPath())) {
return loadable(root);
}
});
try {
ModdedStartup.validatePack(root);
fail("Changed pack must reject stale validation");
} catch (BrokenPackException expected) {
assertNull(PackValidationRegistry.get(root.toPath()));
assertNull(PackValidationRegistry.get(root.getName()));
}
}
}
@Test
public void validationFailurePublishesBlockedRoot() throws Exception {
File root = temporaryFolder.newFolder("terrain");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
validator.when(() -> PackValidator.validate(root)).thenThrow(new IllegalStateException("Invalid terrain"));
PackValidationResult result = ModdedStartup.validatePack(root);
assertFalse(result.isLoadable());
assertSame(result, PackValidationRegistry.get(root.toPath()));
assertSame(result, PackValidationRegistry.get(root.getName()));
}
}
private static PackValidationResult loadable(File root) {
return new PackValidationResult(root.getName(), List.of(), List.of(), System.currentTimeMillis());
}
}
@@ -4,7 +4,10 @@ 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.NativeWorldTeleport;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import org.junit.After;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
@@ -38,6 +41,12 @@ public class ModdedPrimaryWorldRouterTest {
private static final int TICK_INTERVAL = 20;
private static final String PRIMARY = "iris:primary";
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@After
public void clearRouter() {
ModdedPrimaryWorldRouter.clear();
@@ -233,6 +242,41 @@ public class ModdedPrimaryWorldRouterTest {
return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L);
}
@Test
public void disconnectCancelsOldRoutingWithoutAffectingReconnectedPlayer() {
NativeModdedServer server = mock(NativeModdedServer.class);
NativeWorld overworld = mock(NativeWorld.class);
NativeWorld primary = mock(NativeWorld.class);
when(overworld.nativeHandle()).thenReturn(new Object());
when(primary.nativeHandle()).thenReturn(new Object());
when(server.overworld()).thenReturn(overworld);
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
UUID id = UUID.randomUUID();
when(player.id()).thenReturn(id);
when(player.isInWorld(overworld)).thenReturn(true);
onlinePlayers(server, player);
CompletableFuture<Boolean> oldRoute = new CompletableFuture<>();
CompletableFuture<Boolean> newRoute = new CompletableFuture<>();
ModdedModConfig config = config(true, PRIMARY);
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
MockedStatic<NativeWorldTeleport> teleport = mockStatic(NativeWorldTeleport.class)) {
configs.when(ModdedModConfig::get).thenReturn(config);
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(primary);
teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenReturn(oldRoute, newRoute);
evaluate(server);
ModdedPrimaryWorldRouter.forget(id);
assertTrue(oldRoute.isCancelled());
evaluate(server);
oldRoute.complete(true);
evaluate(server);
teleport.verify(() -> NativeWorldTeleport.teleport(any(), any()), times(2));
assertFalse(newRoute.isDone());
ModdedPrimaryWorldRouter.clear();
assertTrue(newRoute.isCancelled());
}
}
private static void evaluate(NativeModdedServer server) {
for (int tick = 0; tick < TICK_INTERVAL; tick++) {
ModdedPrimaryWorldRouter.tick(server);
@@ -5,11 +5,17 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnQueries;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.loading.ResourceLoader;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.SeedManager;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.biome.IrisBiome;
@@ -72,6 +78,15 @@ public class ModdedSavedBiomeConsumersTest {
when(mantle.isChunkLoaded(2, -1)).thenReturn(true);
when(mantle.useChunk(2, -1)).thenReturn(chunk);
BiomeEnvironment environment = environment();
ResourceLoader<IrisSpawner> spawners = mock(ResourceLoader.class);
when(environment.data().getSpawnerLoader()).thenReturn(spawners);
when(spawners.loadAll(any(KList.class))).thenReturn(new KList<>());
when(environment.dimension().getEntitySpawners()).thenReturn(new KList<>());
when(environment.region().getEntitySpawners()).thenReturn(new KList<>());
when(environment.biome().getEntitySpawners()).thenReturn(new KList<>());
when(engine.getSeedManager()).thenReturn(mock(SeedManager.class));
when(engine.getComplex()).thenReturn(mock(IrisComplex.class));
when(engine.openBiomeEnvironmentScope(environment)).thenReturn(mock(BiomeEnvironment.Scope.class));
when(engine.getSurfaceBiomeEnvironment(40, -8))
.thenThrow(new SavedBiomeUnavailableException("Loading", true))
.thenReturn(environment);
@@ -81,8 +96,10 @@ public class ModdedSavedBiomeConsumersTest {
ModdedWorldManager manager = new ModdedWorldManager(engine);
try (MockedStatic<IrisSettings> configured = mockStatic(IrisSettings.class);
MockedStatic<ModdedEntitySpawner> spawns = mockStatic(ModdedEntitySpawner.class)) {
MockedStatic<ModdedEntitySpawner> spawns = mockStatic(ModdedEntitySpawner.class);
MockedStatic<NativeSpawnQueries> queries = mockStatic(NativeSpawnQueries.class)) {
configured.when(IrisSettings::get).thenReturn(settings);
queries.when(() -> NativeSpawnQueries.ambientAllowed(any(NativeWorld.class), eq(2), eq(-1), eq(true))).thenReturn(true);
spawns.when(() -> ModdedEntitySpawner.chunksSafe(any(NativeEntityRuntime.class), eq(2), eq(-1))).thenReturn(true);
assertThrows(SavedBiomeUnavailableException.class, () -> manager.initialSpawnChunk(new ModdedPlatformWorld(level), 2, -1));
verify(chunk, never()).raiseFlagUnchecked(eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG), any());
@@ -57,6 +57,11 @@ public class ModdedStartupReinjectFailureTest {
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
ModdedStartup.warnStartupFailuresTo(operator);
ModdedStartup.warnStartupFailuresTo(player);
NativeProtocolPlayer owner = mock(NativeProtocolPlayer.class);
when(owner.isServerOwner()).thenReturn(true);
ModdedStartup.warnStartupFailuresTo(owner);
verify(owner).sendMessage(argThat((String message) -> message.contains("iris:moon")
&& message.contains("generation history is unusable")));
verify(operator).sendMessage(argThat((String message) -> message.contains("iris:moon")
&& message.contains("generation history is unusable")));
@@ -37,7 +37,7 @@ public class ModdedStructureBindingOrderTest {
}
@Test
public void initialBindingPublishesReadyEngineBeforeStartingStructureSearches() throws Exception {
public void disabledStructuresAllowInitialBindingBeforeStartingStructureSearches() throws Exception {
NativeModdedChunkGenerator<?, ?, ?> nativeGenerator = mock(NativeModdedChunkGenerator.class);
ModdedEngineBinding<Engine> binding = new ModdedEngineBinding<>(1L, TimeUnit.MILLISECONDS);
IrisModdedChunkGenerator generator = generator(nativeGenerator, binding);
@@ -45,7 +45,7 @@ public class ModdedStructureBindingOrderTest {
NativeModdedServer server = mock(NativeModdedServer.class);
Engine engine = readyEngine();
when(nativeGenerator.represents(world)).thenReturn(true);
when(server.generateStructures()).thenReturn(true);
when(server.generateStructures()).thenReturn(false);
doAnswer(invocation -> {
assertSame(engine, generator.awaitStructureEngine());
assertSame(engine, binding.await("overworld"));
@@ -88,7 +88,7 @@ public class ModdedStructureBindingOrderTest {
}
@Test
public void repointPublishesReplacementBindingBeforeStartingStructureSearches() throws Exception {
public void disabledStructuresAllowReplacementBindingBeforeStartingStructureSearches() throws Exception {
NativeModdedChunkGenerator<?, ?, ?> nativeGenerator = mock(NativeModdedChunkGenerator.class);
ModdedEngineBinding<Engine> binding = new ModdedEngineBinding<>(1L, TimeUnit.MILLISECONDS);
IrisModdedChunkGenerator generator = generator(nativeGenerator, binding);
@@ -96,7 +96,7 @@ public class ModdedStructureBindingOrderTest {
NativeModdedServer server = mock(NativeModdedServer.class);
Engine replacement = readyEngine();
when(nativeGenerator.represents(world)).thenReturn(true);
when(server.generateStructures()).thenReturn(true);
when(server.generateStructures()).thenReturn(false);
doAnswer(invocation -> {
assertSame(replacement, generator.awaitStructureEngine());
assertSame(replacement, binding.await("overworld"));
@@ -0,0 +1,114 @@
package art.arcane.iris.modded;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.decoration.IrisSurface;
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.pack.loading.ResourceLoader;
import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisSpawnGroup;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnQueries;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
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.verify;
import static org.mockito.Mockito.when;
public class ModdedUndergroundSpawnOwnershipTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void candidateSamplingAndPoolSelectionUseAbsoluteUndergroundOwner() throws Exception {
NativeWorld world = mock(NativeWorld.class);
when(world.minHeight()).thenReturn(-64);
when(world.maxHeight()).thenReturn(320);
NativeBlockState stone = mock(NativeBlockState.class);
NativeBlockState air = mock(NativeBlockState.class);
when(air.isAir()).thenReturn(true);
when(world.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(call ->
(int) call.getArgument(1) <= -21 ? stone : air);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build());
IrisBiome cave = new IrisBiome().setEntitySpawners(new KList<>("residents"));
IrisDimension dimension = new IrisDimension();
IrisRegion region = new IrisRegion();
IrisData data = mock(IrisData.class);
@SuppressWarnings("unchecked")
ResourceLoader<IrisSpawner> loader = mock(ResourceLoader.class);
when(data.getSpawnerLoader()).thenReturn(loader);
when(loader.loadAll(dimension.getEntitySpawners())).thenReturn(new KList<>());
when(loader.loadAll(region.getEntitySpawners())).thenReturn(new KList<>());
IrisEntitySpawn entry = mock(IrisEntitySpawn.class);
IrisEntity entity = mock(IrisEntity.class);
when(entry.getRealEntity(engine)).thenReturn(entity);
when(entry.getRarity()).thenReturn(1);
when(entry.getMinSpawns()).thenReturn(1);
when(entry.getMaxSpawns()).thenReturn(1);
when(entity.getSurface()).thenReturn(IrisSurface.LAND);
when(entity.spawnCategory()).thenReturn("creature");
IrisSpawner spawner = new IrisSpawner().setGroup(IrisSpawnGroup.CAVE).setInitialSpawns(new KList<>(entry));
when(loader.loadAll(cave.getEntitySpawners())).thenReturn(new KList<>(spawner));
BiomeEnvironment environment = new BiomeEnvironment(9L, cave, region, dimension, data);
when(engine.getBiomeOrMantleEnvironment(anyInt(), eq(44), anyInt())).thenReturn(environment);
BiomeEnvironment.Scope scope = mock(BiomeEnvironment.Scope.class);
when(engine.openBiomeEnvironmentScope(environment)).thenReturn(scope);
NativeEntityRuntime runtime = mock(NativeEntityRuntime.class);
when(runtime.world()).thenReturn(world);
ModdedWorldManager manager = new ModdedWorldManager(engine);
try (MockedStatic<NativeSpawnQueries> queries = mockStatic(NativeSpawnQueries.class);
MockedStatic<ModdedEntitySpawner> entities = mockStatic(ModdedEntitySpawner.class)) {
queries.when(() -> NativeSpawnQueries.surfaceHeight(eq(world), anyInt(), anyInt(), eq(true))).thenReturn(64);
queries.when(() -> NativeSpawnQueries.solid(stone)).thenReturn(true);
IrisPosition first = ModdedWorldManager.findUndergroundCandidate(world, 2, -1, new RNG(88771L), IrisSurface.LAND);
IrisPosition second = ModdedWorldManager.findUndergroundCandidate(world, 2, -1, new RNG(88771L), IrisSurface.LAND);
assertNotNull(first);
assertEquals(first, second);
assertEquals(-20, first.getY());
assertTrue(first.getX() >= 33 && first.getX() <= 47 && first.getZ() >= -15 && first.getZ() <= -1);
Field field = ModdedWorldManager.class.getDeclaredField("entityRuntime");
field.setAccessible(true);
field.set(manager, runtime);
entities.when(() -> ModdedEntitySpawner.isAreaClearForSpawn(eq(runtime), eq(entity), anyInt(), eq(-20), anyInt()))
.thenReturn(true);
Method spawn = ModdedWorldManager.class.getDeclaredMethod("spawnUnderground", NativeWorld.class,
int.class, int.class, boolean.class, Map.class, RNG.class);
spawn.setAccessible(true);
spawn.invoke(manager, world, 2, -1, true, Map.of(), new RNG(88771L));
entities.verify(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), eq(runtime), anyInt(), eq(-20), anyInt(), any(RNG.class)));
} finally {
manager.close();
}
verify(scope).close();
}
}
@@ -14,6 +14,7 @@ import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisSpawnGroup;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext;
import art.arcane.iris.generation.decoration.IrisSurface;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.collection.KList;
@@ -196,7 +197,7 @@ public class ModdedWaterEntitySpawnTest {
doReturn(entity).when(entry).getRealEntity(engine);
IrisSpawner spawner = new IrisSpawner().setGroup(IrisSpawnGroup.CAVE);
Method spawn = ModdedWorldManager.class.getDeclaredMethod("spawnEntry", NativeWorld.class,
IrisEntitySpawn.class, IrisSpawner.class, int.class, int.class, int.class);
IrisEntitySpawn.class, int.class, int.class, int.class, RNG.class, SpawnContext.class);
spawn.setAccessible(true);
ModdedWorldManager manager = new ModdedWorldManager(engine);
try (MockedStatic<ModdedEntitySpawner> entities = mockStatic(ModdedEntitySpawner.class)) {
@@ -205,7 +206,7 @@ public class ModdedWaterEntitySpawnTest {
entities.when(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), any(NativeEntityRuntime.class),
eq(-25), eq(-49), eq(57), any(RNG.class))).thenReturn(mock(NativeSpawnedEntity.class));
assertEquals(1, spawn.invoke(manager, world, entry, spawner, -2, 3, 1));
assertEquals(1, spawn.invoke(manager, world, entry, -2, 3, 1, new RNG(1L), new SpawnContext(spawner, null, false)));
entities.verify(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), any(NativeEntityRuntime.class),
eq(-25), eq(-49), eq(57), any(RNG.class)));
@@ -7,7 +7,6 @@ import org.junit.BeforeClass;
import org.junit.Test;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class NativeStructureFailureContractTest {
@@ -17,21 +16,6 @@ public class NativeStructureFailureContractTest {
Bootstrap.bootStrap();
}
@Test
public void globalStructureDisableRefusesGeneratorBinding() {
IrisModdedChunkGenerator.requireGlobalStructureGeneration(true, "overworld:overworld");
IllegalStateException error = assertThrows(
IllegalStateException.class,
() -> IrisModdedChunkGenerator.requireGlobalStructureGeneration(
false, "overworld:overworld"));
assertTrue(error.getMessage().contains("overworld:overworld"));
assertTrue(error.getMessage().contains("generate-structures=false"));
assertTrue(error.getMessage().contains("importedStructures.disabled"));
assertTrue(error.getMessage().contains("importedStructures.disabledExact"));
}
@Test
public void structureFailurePreservesPhaseIdentityChunkAndCause() {
IllegalArgumentException cause = new IllegalArgumentException("broken placement");
@@ -21,6 +21,8 @@ import net.minecraft.server.level.ServerChunkCache;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.Level;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.entity.Pose;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
@@ -98,6 +100,7 @@ public class IrisModdedCommandsTeleportTest {
when(server.getLevel(dimension)).thenReturn(serverLevel);
ServerPlayer serverPlayer = mock(ServerPlayer.class);
when(serverPlayer.getUUID()).thenReturn(UUID.randomUUID());
when(serverPlayer.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 1.8F));
NativeWorld level = mock(NativeWorld.class);
when(level.nativeHandle()).thenReturn(serverLevel);
@@ -0,0 +1,93 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.generation.context.IrisContext;
import art.arcane.iris.generation.locator.BiomeLocator;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.generation.subterrain.SubterrainPosition;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.math.Position2;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
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.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class ModdedCaveLocateTest {
@Test
public void caveTeleportUsesValidatedUndergroundPosition() throws Exception {
Fixture fixture = new Fixture();
when(fixture.player.teleport(fixture.world, 8.5, -42, 8.5)).thenReturn(true);
try (MockedStatic<IrisModdedCommands> commands = mockStatic(IrisModdedCommands.class);
MockedStatic<IrisContext> context = mockStatic(IrisContext.class)) {
fixture.context(context);
fixture.teleport();
verify(fixture.player).teleport(fixture.world, 8.5, -42, 8.5);
commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString()));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()), never());
}
}
@Test
public void failedTeleportNeverReportsSuccess() throws Exception {
Fixture fixture = new Fixture();
try (MockedStatic<IrisModdedCommands> commands = mockStatic(IrisModdedCommands.class);
MockedStatic<IrisContext> context = mockStatic(IrisContext.class)) {
fixture.context(context);
fixture.teleport();
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()));
commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString()), never());
}
}
@Test
public void worldChangeCancelsBeforeCaveLookupOrTeleport() throws Exception {
Fixture fixture = new Fixture();
when(fixture.player.inWorld(fixture.world)).thenReturn(false);
try (MockedStatic<IrisModdedCommands> commands = mockStatic(IrisModdedCommands.class)) {
fixture.teleport();
verifyNoInteractions(fixture.locator);
verify(fixture.player, never()).teleport(any(), anyDouble(), anyDouble(), anyDouble());
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()));
}
}
private static final class Fixture {
private final NativeCommandSource source = mock(NativeCommandSource.class);
private final NativeWorld world = mock(NativeWorld.class);
private final NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
private final Engine engine = mock(Engine.class);
private final BiomeLocator locator = mock(BiomeLocator.class);
private Fixture() throws Exception {
when(player.connected()).thenReturn(true);
when(player.inWorld(world)).thenReturn(true);
when(engine.acquireGenerationLease("modded_locator_teleport")).thenReturn(mock(GenerationSessionLease.class));
SubterrainPosition target = new SubterrainPosition(8, -40, 8);
when(locator.position(engine, new Position2(0, 0))).thenReturn(target);
when(locator.landing(engine, world, target, Optional.empty())).thenReturn(new SubterrainPosition(8, -42, 8));
}
private void context(MockedStatic<IrisContext> context) {
context.when(() -> IrisContext.open(eq(engine), anyLong(), any())).thenReturn(mock(IrisContext.Scope.class));
}
private void teleport() {
ModdedLocateCommands.teleportToLocateResult(source, world, engine, player, "biome deep", locator,
new Position2(0, 0), Optional.of(new ModdedLocateCommands.CaveDestination(
new SubterrainPosition(8, -40, 8), Optional.empty())));
}
}
}
@@ -0,0 +1,212 @@
package art.arcane.iris.modded.service;
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner.SplashPackMetadata;
import art.arcane.iris.pack.BuiltInPackUpdates;
import art.arcane.iris.spi.IrisPlatform;
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 net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.LevelBasedPermissionSet;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.io.File;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
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.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
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 ModdedPackUpdateServiceTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void pendingCheckNotifiesOnlineAndLaterOperatorsOnceOnServerTick() {
CompletableFuture<ModdedPackUpdateService.PackCheck> pending = new CompletableFuture<>();
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> pending);
NativeProtocolPlayer online = player(true, false);
NativeModdedServer server = server(online);
File root = new File("config/irisworldgen/packs").getAbsoluteFile();
try (MockedStatic<IrisPlatforms> platforms = platform(root)) {
service.onEnable();
service.onServerTick(server);
service.notifyPlayer(online);
verify(online, never()).sendMessage(anyString());
pending.complete(outdated());
verify(online, never()).sendMessage(anyString());
service.onServerTick(server);
service.onServerTick(server);
service.notifyPlayer(online);
verify(online, times(1)).sendMessage(contains("installed v1, latest built-in release v2"));
verify(online, times(1)).sendMessage(contains(new File(root, "overworld").toString()));
verify(online, times(1)).sendMessage(contains("/iris download pack=overworld overwrite=true"));
verify(online, times(1)).sendMessage(contains("Back up the complete world"));
verify(online, times(1)).sendMessage(contains("/iris world update <dimension> overworld"));
verify(online, times(1)).sendMessage(contains("restart the server to activate it"));
verify(online, never()).sendMessage(contains("plugins/"));
NativeProtocolPlayer later = player(true, false);
service.notifyPlayer(later);
verify(later).sendMessage(contains("release metadata is outdated"));
service.onDisable();
}
}
@Test
public void integratedOwnerWithoutCheatsReceivesNoticeAndRemoteOrdinaryPlayersDoNot() {
NativeProtocolPlayer owner = player(false, true);
NativeProtocolPlayer remote = player(false, false);
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> CompletableFuture.completedFuture(outdated()));
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
service.onEnable();
service.onServerTick(server(owner));
verify(owner).sendMessage(contains("release metadata is outdated"));
service.notifyPlayer(remote);
verify(remote, never()).sendMessage(anyString());
service.onDisable();
}
}
@Test
public void disableCancelsCheckAndRestartDoesNotPublishOldNotices() {
CompletableFuture<ModdedPackUpdateService.PackCheck> first = new CompletableFuture<>();
CompletableFuture<ModdedPackUpdateService.PackCheck> second = new CompletableFuture<>();
AtomicInteger checks = new AtomicInteger();
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> checks.getAndIncrement() == 0 ? first : second);
NativeProtocolPlayer operator = player(true, false);
NativeModdedServer server = server(operator);
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
service.onEnable();
service.onServerTick(server);
service.onDisable();
assertTrue(first.isCancelled());
assertFalse(first.complete(outdated()));
service.onEnable();
service.onServerTick(server);
second.complete(new ModdedPackUpdateService.PackCheck(List.of(), Map.of()));
service.onServerTick(server);
verify(operator, never()).sendMessage(anyString());
assertEquals(2, checks.get());
service.onDisable();
}
}
@Test
public void currentCustomUnknownAndUnavailableVersionsNeverBecomeOutdated() {
ModdedPackUpdateService.PackCheck result = new ModdedPackUpdateService.PackCheck(List.of(
new SplashPackMetadata("overworld", "unknown"),
new SplashPackMetadata("underworld", "3"),
new SplashPackMetadata("custom", "1")), Map.of(
"overworld", new BuiltInPackUpdates.Update("2"),
"underworld", new BuiltInPackUpdates.Update("2")));
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> CompletableFuture.completedFuture(result));
NativeProtocolPlayer operator = player(true, false);
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
service.onEnable();
service.onServerTick(server(operator));
verify(operator, never()).sendMessage(anyString());
service.onDisable();
}
}
@Test
public void successfulAuthoringUpdateClearsOldNoticesAndRescansInstalledVersion() {
CompletableFuture<ModdedPackUpdateService.PackCheck> current = new CompletableFuture<>();
AtomicInteger checks = new AtomicInteger();
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> checks.getAndIncrement() == 0
? CompletableFuture.completedFuture(outdated()) : current);
NativeProtocolPlayer operator = player(true, false);
NativeModdedServer server = server(operator);
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
service.onEnable();
service.onServerTick(server);
verify(operator).sendMessage(contains("release metadata is outdated"));
service.refresh();
NativeProtocolPlayer later = player(true, false);
service.notifyPlayer(later);
verify(later, never()).sendMessage(anyString());
service.onServerTick(server);
current.complete(new ModdedPackUpdateService.PackCheck(List.of(new SplashPackMetadata("overworld", "2")),
Map.of("overworld", new BuiltInPackUpdates.Update("2"))));
service.onServerTick(server);
service.notifyPlayer(later);
verify(later, never()).sendMessage(anyString());
assertEquals(2, checks.get());
service.onDisable();
}
}
@Test
public void nativeOwnerCheckDoesNotGrantGameMasterPermissions() {
ServerPlayer handle = mock(ServerPlayer.class);
ServerLevel level = mock(ServerLevel.class);
MinecraftServer server = mock(MinecraftServer.class);
when(handle.level()).thenReturn(level);
when(level.getServer()).thenReturn(server);
when(handle.permissions()).thenReturn(LevelBasedPermissionSet.ALL);
NativeProtocolPlayer player = NativeProtocolPlayer.fromHandle(handle);
when(server.isSingleplayerOwner(handle.nameAndId())).thenReturn(true);
assertTrue(player.isServerOwner());
assertFalse(player.isGameMaster());
when(server.isSingleplayerOwner(handle.nameAndId())).thenReturn(false);
assertFalse(player.isServerOwner());
assertFalse(player.isGameMaster());
}
private static ModdedPackUpdateService.PackCheck outdated() {
return new ModdedPackUpdateService.PackCheck(List.of(new SplashPackMetadata("overworld", "1")),
Map.of("overworld", new BuiltInPackUpdates.Update("2")));
}
private static MockedStatic<IrisPlatforms> platform(File root) {
IrisPlatform platform = mock(IrisPlatform.class);
when(platform.packsFolderNoCreate()).thenReturn(root);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
platforms.when(IrisPlatforms::get).thenReturn(platform);
return platforms;
}
private static NativeProtocolPlayer player(boolean operator, boolean owner) {
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
when(player.id()).thenReturn(UUID.randomUUID());
when(player.connected()).thenReturn(true);
when(player.isGameMaster()).thenReturn(operator);
when(player.isServerOwner()).thenReturn(owner);
return player;
}
@SuppressWarnings("unchecked")
private static NativeModdedServer server(NativeProtocolPlayer player) {
NativeModdedServer server = mock(NativeModdedServer.class);
when(server.hasPlayerList()).thenReturn(true);
doAnswer(invocation -> {
invocation.getArgument(0, Consumer.class).accept(player);
return null;
}).when(server).forEachPlayer(any());
return server;
}
}
@@ -4,14 +4,65 @@ import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.modded.ModdedScheduler;
import art.arcane.volmlib.util.collection.KList;
import org.junit.Test;
import org.mockito.InOrder;
import java.lang.reflect.Field;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedStudioHotloadServiceTest {
@Test
public void shutdownDrainsWorkerBeforeAllowingRestart() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
ExecutorService executor = mock(ExecutorService.class);
setExecutor(service, executor);
when(executor.awaitTermination(30L, TimeUnit.SECONDS)).thenReturn(false);
when(executor.awaitTermination(5L, TimeUnit.SECONDS)).thenReturn(true);
service.onDisable();
InOrder order = inOrder(executor);
order.verify(executor).shutdown();
order.verify(executor).awaitTermination(30L, TimeUnit.SECONDS);
order.verify(executor).shutdownNow();
order.verify(executor).awaitTermination(5L, TimeUnit.SECONDS);
service.onEnable();
service.onDisable();
}
@Test
public void undrainedWorkerPreventsRestart() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
ExecutorService executor = mock(ExecutorService.class);
setExecutor(service, executor);
when(executor.isShutdown()).thenReturn(true);
try {
service.onDisable();
fail("Hotload shutdown must report a worker that did not terminate");
} catch (IllegalStateException expected) {
verify(executor).shutdownNow();
}
try {
service.onEnable();
fail("Hotload must not start another worker before the previous worker terminates");
} catch (IllegalStateException expected) {
assertFalse(executor.isTerminated());
}
when(executor.isTerminated()).thenReturn(true);
service.onEnable();
service.onDisable();
}
@Test
public void detectsDatapackImportsAcrossLoadedDimensions() {
IrisDimension empty = new IrisDimension();
@@ -45,4 +96,10 @@ public class ModdedStudioHotloadServiceTest {
scheduler.shutdown();
}
}
private static void setExecutor(ModdedStudioHotloadService service, ExecutorService executor) throws Exception {
Field field = ModdedStudioHotloadService.class.getDeclaredField("executor");
field.setAccessible(true);
field.set(service, executor);
}
}
@@ -0,0 +1,142 @@
package art.arcane.volmlib.nativelib.minecraft26_2.modded;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerChunkCache;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.entity.Pose;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.AABB;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
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.anyInt;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class NativeTeleportSafetyTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void rejectsTallAndHazardousSupportsAndAcceptsSolidFloor() {
ServerPlayer player = player();
ServerLevel level = level();
for (BlockState support : new BlockState[]{Blocks.OAK_FENCE.defaultBlockState(),
Blocks.MAGMA_BLOCK.defaultBlockState(), Blocks.CACTUS.defaultBlockState()}) {
setSupport(level, support);
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
}
setSupport(level, Blocks.STONE.defaultBlockState());
assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
}
@Test
public void fractionalPositionUsesWholePlayerBodyAndRequiresNeighborChunk() {
ServerPlayer player = player();
ServerLevel level = level();
setSupport(level, Blocks.STONE.defaultBlockState());
when(level.noCollision(any(), any(AABB.class))).thenAnswer(invocation -> {
AABB body = invocation.getArgument(1);
return body.maxX <= 16D;
});
assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.5D, 4, 8.5D));
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.9D, 4, 8.5D));
when(level.noCollision(any(), any(AABB.class))).thenReturn(true);
when(level.getChunkSource().hasChunk(1, 0)).thenReturn(false);
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.9D, 4, 8.5D));
EntityDimensions dimensions = player.getDimensions(Pose.STANDING);
assertEquals(0, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(15.5D, 4D, 8.5D), new ChunkPos(0, 0)));
assertEquals(1, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(15.9D, 4D, 8.5D), new ChunkPos(0, 0)));
assertEquals(1, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(-16.1D, 4D, -8.5D), new ChunkPos(-2, -1)));
}
@Test
public void scaledStandingBodyStaysInsideActualWorldHeight() {
ServerLevel level = level();
setSupport(level, Blocks.STONE.defaultBlockState());
ServerPlayer player = player();
when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 13F));
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 12F));
assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 0, 8.5D));
}
@Test
public void reconnectDuringChunkWarmupDoesNotTeleportReplacementSession() {
MinecraftServer server = mock(MinecraftServer.class, RETURNS_DEEP_STUBS);
when(server.isSameThread()).thenReturn(true);
ServerLevel level = level();
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, Identifier.parse("iris:test"));
when(level.getServer()).thenReturn(server);
when(level.dimension()).thenReturn(key);
when(server.getLevel(key)).thenReturn(level);
ServerChunkCache chunks = level.getChunkSource();
when(chunks.hasChunk(anyInt(), anyInt())).thenReturn(false);
CompletableFuture<Void> loaded = new CompletableFuture<>();
doReturn(loaded).when(chunks).addTicketAndLoadWithRadius(any(), any(), anyInt());
ServerPlayer original = player();
UUID id = UUID.randomUUID();
when(original.getUUID()).thenReturn(id);
when(server.getPlayerList().getPlayer(id)).thenReturn(original);
NativeWorld world = mock(NativeWorld.class);
when(world.nativeHandle()).thenReturn(level);
CompletableFuture<Boolean> result = NativeWorldTeleport.teleport(NativeProtocolPlayer.fromHandle(original),
new NativeWorldTeleport.Destination(NativeModdedServer.fromHandle(server), world, 8.5D, 4D, 8.5D, 0L));
assertFalse(result.isDone());
ServerPlayer replacement = player();
when(server.getPlayerList().getPlayer(id)).thenReturn(replacement);
loaded.complete(null);
assertFalse(result.join());
verify(chunks).removeTicketWithRadius(any(), any(), anyInt());
}
private ServerPlayer player() {
ServerPlayer player = mock(ServerPlayer.class);
when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 1.8F));
return player;
}
private ServerLevel level() {
ServerLevel level = mock(ServerLevel.class);
ServerChunkCache chunks = mock(ServerChunkCache.class);
when(level.getChunkSource()).thenReturn(chunks);
when(level.getMinY()).thenReturn(0);
when(level.getMaxY()).thenReturn(16);
when(chunks.hasChunk(anyInt(), anyInt())).thenReturn(true);
when(level.noCollision(any(), any(AABB.class))).thenReturn(true);
return level;
}
private void setSupport(ServerLevel level, BlockState support) {
when(level.getBlockState(any(BlockPos.class))).thenAnswer(invocation -> {
BlockPos position = invocation.getArgument(0);
return position.getY() == 3 ? support : Blocks.AIR.defaultBlockState();
});
}
}

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