Fix Iris replacement

This commit is contained in:
Brian Neumann-Fopiano
2026-08-15 15:30:42 -04:00
parent 4568b3fff1
commit 8297e49ed2
52 changed files with 4748 additions and 198 deletions
@@ -861,7 +861,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
}
try {
WorldgenTerrainHeightmaps.primeStructurePlacement(
world, heightmapStarts, worldgenSurfaceHeight(), worldgenFloorHeight());
world, chunkPos, heightmapStarts, worldgenSurfaceHeight(), worldgenFloorHeight());
} catch (Throwable error) {
throw NativeStructureGenerationException.failure(
"heightmap priming", nativeStructureBatchContext(placementGroups),
@@ -932,9 +932,16 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
private void placeVanillaStructure(WorldGenLevel world, StructureManager structureManager, WorldgenRandom random,
BoundingBox area, ChunkPos chunkPos, String structureId, StructureStart start,
IrisNativeStructureDecision decision) {
NativeStructurePostProcessor.place(world, structureManager, this, random, area, chunkPos,
structureId, start, decision, this::resolvePaletteBlock,
(x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight());
WorldGenLevel boundedWorld = NativeStructureWorldgenAccess.create(
world, chunkPos, worldgenSurfaceHeight(), worldgenFloorHeight());
world.setCurrentlyGenerating(() -> "Iris native structure " + structureId);
try {
NativeStructurePostProcessor.place(boundedWorld, structureManager, this, random, area, chunkPos,
structureId, start, decision, this::resolvePaletteBlock,
(x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight());
} finally {
world.setCurrentlyGenerating(null);
}
}
private List<List<Structure>> structuresByStep(Registry<Structure> registry) {
@@ -0,0 +1,573 @@
package art.arcane.iris.core.nms.v26_2_R1;
import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps;
import it.unimi.dsi.fastutil.longs.Long2LongOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.Holder;
import net.minecraft.core.QuartPos;
import net.minecraft.core.RegistryAccess;
import net.minecraft.core.particles.ParticleOptions;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.util.Mth;
import net.minecraft.util.RandomSource;
import net.minecraft.world.DifficultyInstance;
import net.minecraft.world.attribute.EnvironmentAttributeReader;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.flag.FeatureFlagSet;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.LightLayer;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.border.WorldBorder;
import net.minecraft.world.level.chunk.ChunkAccess;
import net.minecraft.world.level.chunk.ChunkSource;
import net.minecraft.world.level.chunk.EmptyLevelChunk;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.dimension.DimensionType;
import net.minecraft.world.level.entity.EntityTypeTest;
import net.minecraft.world.level.gameevent.GameEvent;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.lighting.LevelLightEngine;
import net.minecraft.world.level.material.Fluid;
import net.minecraft.world.level.material.FluidState;
import net.minecraft.world.level.storage.LevelData;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.ticks.LevelTickAccess;
import net.minecraft.world.ticks.ScheduledTick;
import org.bukkit.event.entity.CreatureSpawnEvent;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.IntBinaryOperator;
import java.util.function.Predicate;
import java.util.function.Supplier;
final class NativeStructureWorldgenAccess implements WorldGenLevel {
private static final int WRITE_RADIUS = 1;
private final WorldGenLevel delegate;
private final ChunkPos generationCenter;
private final IntBinaryOperator surfaceFirstFreeY;
private final IntBinaryOperator floorFirstFreeY;
private final Holder<Biome> fallbackBiome;
private final BiomeManager biomeManager;
private final AABB generationBounds;
private final LevelTickAccess<Block> blockTicks;
private final LevelTickAccess<Fluid> fluidTicks;
private final Long2LongOpenHashMap terrainHeights;
private final Long2ObjectOpenHashMap<ChunkAccess> outsideChunks;
private NativeStructureWorldgenAccess(WorldGenLevel delegate, Boundary boundary) {
this.delegate = Objects.requireNonNull(delegate, "Native structure world access requires a delegate");
this.generationCenter = Objects.requireNonNull(
boundary.generationCenter(), "Native structure world access requires a generation center");
this.surfaceFirstFreeY = Objects.requireNonNull(
boundary.surfaceFirstFreeY(), "Native structure world access requires a surface resolver");
this.floorFirstFreeY = Objects.requireNonNull(
boundary.floorFirstFreeY(), "Native structure world access requires an ocean-floor resolver");
BlockPos biomeSample = generationCenter.getMiddleBlockPosition(delegate.getSeaLevel());
this.fallbackBiome = delegate.getBiome(biomeSample);
this.biomeManager = delegate.getBiomeManager().withDifferentSource(this);
this.generationBounds = new AABB(
generationCenter.getMinBlockX() - 16,
delegate.getMinY(),
generationCenter.getMinBlockZ() - 16,
generationCenter.getMaxBlockX() + 17,
delegate.getMinY() + delegate.getHeight(),
generationCenter.getMaxBlockZ() + 17);
this.blockTicks = new BoundedTickAccess<>(delegate.getBlockTicks(), this::isWritable);
this.fluidTicks = new BoundedTickAccess<>(delegate.getFluidTicks(), this::isWritable);
this.terrainHeights = new Long2LongOpenHashMap();
this.terrainHeights.defaultReturnValue(Long.MIN_VALUE);
this.outsideChunks = new Long2ObjectOpenHashMap<>();
}
static NativeStructureWorldgenAccess create(WorldGenLevel delegate, ChunkPos generationCenter,
IntBinaryOperator surfaceFirstFreeY,
IntBinaryOperator floorFirstFreeY) {
return new NativeStructureWorldgenAccess(delegate, new Boundary(
generationCenter, surfaceFirstFreeY, floorFirstFreeY));
}
@Override
public long getSeed() {
return delegate.getSeed();
}
@Override
public boolean ensureCanWrite(BlockPos position) {
return isWritable(position) && delegate.ensureCanWrite(position);
}
@Override
public void setCurrentlyGenerating(Supplier<String> description) {
delegate.setCurrentlyGenerating(description);
}
@Override
public ServerLevel getLevel() {
return delegate.getLevel();
}
@Override
public DifficultyInstance getCurrentDifficultyAt(BlockPos position) {
if (isReadable(position)) {
return delegate.getCurrentDifficultyAt(position);
}
return delegate.getCurrentDifficultyAt(generationCenter.getMiddleBlockPosition(
Mth.clamp(position.getY(), getMinY(), getMaxY() - 1)));
}
@Override
public long nextSubTickCount() {
return delegate.nextSubTickCount();
}
@Override
public LevelTickAccess<Block> getBlockTicks() {
return blockTicks;
}
@Override
public LevelTickAccess<Fluid> getFluidTicks() {
return fluidTicks;
}
@Override
public LevelData getLevelData() {
return delegate.getLevelData();
}
@Override
public MinecraftServer getServer() {
return delegate.getServer();
}
@Override
public ChunkSource getChunkSource() {
return delegate.getChunkSource();
}
@Override
public RandomSource getRandom() {
return delegate.getRandom();
}
@Override
public void updateNeighborsAt(BlockPos position, Block block) {
if (isWritableNeighbourhood(position)) {
delegate.updateNeighborsAt(position, block);
}
}
@Override
public void neighborShapeChanged(Direction direction, BlockPos position,
BlockPos neighbourPosition, BlockState neighbourState,
int updateFlags, int updateLimit) {
if (isWritable(position) && isWritable(neighbourPosition)) {
delegate.neighborShapeChanged(
direction, position, neighbourPosition, neighbourState, updateFlags, updateLimit);
}
}
@Override
public void playSound(Entity source, BlockPos position, SoundEvent sound,
SoundSource soundSource, float volume, float pitch) {
if (isWritable(position)) {
delegate.playSound(source, position, sound, soundSource, volume, pitch);
}
}
@Override
public void addParticle(ParticleOptions particle, double x, double y, double z,
double velocityX, double velocityY, double velocityZ) {
if (isWritable(BlockPos.containing(x, y, z))) {
delegate.addParticle(particle, x, y, z, velocityX, velocityY, velocityZ);
}
}
@Override
public void levelEvent(Entity source, int eventId, BlockPos position, int data) {
if (isWritable(position)) {
delegate.levelEvent(source, eventId, position, data);
}
}
@Override
public void gameEvent(Holder<GameEvent> event, Vec3 position, GameEvent.Context context) {
if (isWritable(BlockPos.containing(position))) {
delegate.gameEvent(event, position, context);
}
}
@Override
public ChunkAccess getChunk(int chunkX, int chunkZ, ChunkStatus status, boolean create) {
if (isInsideGenerationRegion(chunkX, chunkZ)) {
return delegate.getChunk(chunkX, chunkZ, status, create);
}
return create ? outsideChunk(chunkX, chunkZ) : null;
}
@Override
public ChunkAccess getChunkIfLoadedImmediately(int chunkX, int chunkZ) {
if (!isInsideGenerationRegion(chunkX, chunkZ)) {
return null;
}
return delegate.getChunkIfLoadedImmediately(chunkX, chunkZ);
}
@Override
public boolean hasChunk(int chunkX, int chunkZ) {
return isInsideGenerationRegion(chunkX, chunkZ) && delegate.hasChunk(chunkX, chunkZ);
}
@Override
public int getHeight(Heightmap.Types type, int x, int z) {
if (isInsideGenerationRegion(x >> 4, z >> 4)) {
return delegate.getHeight(type, x, z);
}
return height(type, x, z);
}
@Override
public int getSkyDarken() {
return delegate.getSkyDarken();
}
@Override
public BiomeManager getBiomeManager() {
return biomeManager;
}
@Override
public Holder<Biome> getNoiseBiome(int quartX, int quartY, int quartZ) {
return getUncachedNoiseBiome(quartX, quartY, quartZ);
}
@Override
public Holder<Biome> getUncachedNoiseBiome(int quartX, int quartY, int quartZ) {
if (isInsideGenerationRegion(QuartPos.toSection(quartX), QuartPos.toSection(quartZ))) {
return delegate.getUncachedNoiseBiome(quartX, quartY, quartZ);
}
return fallbackBiome;
}
@Override
public boolean isClientSide() {
return delegate.isClientSide();
}
@Override
public int getSeaLevel() {
return delegate.getSeaLevel();
}
@Override
public DimensionType dimensionType() {
return delegate.dimensionType();
}
@Override
public int getMinY() {
return delegate.getMinY();
}
@Override
public int getHeight() {
return delegate.getHeight();
}
@Override
public RegistryAccess registryAccess() {
return delegate.registryAccess();
}
@Override
public FeatureFlagSet enabledFeatures() {
return delegate.enabledFeatures();
}
@Override
public EnvironmentAttributeReader environmentAttributes() {
return delegate.environmentAttributes();
}
@Override
public LevelLightEngine getLightEngine() {
return delegate.getLightEngine();
}
@Override
public int getBrightness(LightLayer layer, BlockPos position) {
if (isReadable(position)) {
return delegate.getBrightness(layer, position);
}
return layer == LightLayer.SKY && canSeeSky(position) ? 15 : 0;
}
@Override
public int getRawBrightness(BlockPos position, int ambientDarkening) {
if (isReadable(position)) {
return delegate.getRawBrightness(position, ambientDarkening);
}
return Math.max(0, getBrightness(LightLayer.SKY, position) - ambientDarkening);
}
@Override
public boolean canSeeSky(BlockPos position) {
if (isReadable(position)) {
return delegate.canSeeSky(position);
}
return position.getY() >= height(Heightmap.Types.WORLD_SURFACE_WG, position.getX(), position.getZ());
}
@Override
public WorldBorder getWorldBorder() {
return delegate.getWorldBorder();
}
@Override
public BlockGetter getChunkForCollisions(int chunkX, int chunkZ) {
if (isInsideGenerationRegion(chunkX, chunkZ)) {
return delegate.getChunkForCollisions(chunkX, chunkZ);
}
return outsideChunk(chunkX, chunkZ);
}
@Override
public BlockEntity getBlockEntity(BlockPos position) {
return isReadable(position) ? delegate.getBlockEntity(position) : null;
}
@Override
public <T extends BlockEntity> Optional<T> getBlockEntity(
BlockPos position, BlockEntityType<T> type) {
return isReadable(position) ? delegate.getBlockEntity(position, type) : Optional.empty();
}
@Override
public BlockState getBlockState(BlockPos position) {
return isReadable(position) ? delegate.getBlockState(position) : terrainState(position);
}
@Override
public BlockState getBlockStateIfLoaded(BlockPos position) {
return isReadable(position) ? delegate.getBlockStateIfLoaded(position) : null;
}
@Override
public FluidState getFluidState(BlockPos position) {
return isReadable(position) ? delegate.getFluidState(position) : terrainState(position).getFluidState();
}
@Override
public FluidState getFluidIfLoaded(BlockPos position) {
return isReadable(position) ? delegate.getFluidIfLoaded(position) : null;
}
@Override
public List<Entity> getEntities(Entity source, AABB area, Predicate<? super Entity> predicate) {
AABB boundedArea = boundedArea(area);
return boundedArea == null ? List.of() : delegate.getEntities(source, boundedArea, predicate);
}
@Override
public <T extends Entity> List<T> getEntities(
EntityTypeTest<Entity, T> type,
AABB area, Predicate<? super T> predicate) {
AABB boundedArea = boundedArea(area);
return boundedArea == null ? List.of() : delegate.getEntities(type, boundedArea, predicate);
}
@Override
public List<? extends Player> players() {
return delegate.players();
}
@Override
public boolean isStateAtPosition(BlockPos position, Predicate<BlockState> predicate) {
return predicate.test(getBlockState(position));
}
@Override
public boolean isFluidAtPosition(BlockPos position, Predicate<FluidState> predicate) {
return predicate.test(getFluidState(position));
}
@Override
public BlockPos getHeightmapPos(Heightmap.Types type, BlockPos position) {
return position.atY(getHeight(type, position.getX(), position.getZ()));
}
@Override
public boolean setBlock(BlockPos position, BlockState state, int updateFlags, int updateLimit) {
return isWritable(position) && delegate.setBlock(position, state, updateFlags, updateLimit);
}
@Override
public boolean removeBlock(BlockPos position, boolean move) {
return isWritable(position) && delegate.removeBlock(position, move);
}
@Override
public boolean destroyBlock(BlockPos position, boolean drop, Entity source, int updateLimit) {
return isWritable(position) && delegate.destroyBlock(position, drop, source, updateLimit);
}
@Override
public boolean addFreshEntity(Entity entity) {
return isWritable(entity.blockPosition()) && delegate.addFreshEntity(entity);
}
@Override
public boolean addFreshEntity(Entity entity, CreatureSpawnEvent.SpawnReason reason) {
return isWritable(entity.blockPosition()) && delegate.addFreshEntity(entity, reason);
}
private boolean isReadable(BlockPos position) {
return isInsideGenerationRegion(position.getX() >> 4, position.getZ() >> 4)
&& !isOutsideBuildHeight(position);
}
private boolean isWritable(BlockPos position) {
return isReadable(position);
}
private boolean isWritableNeighbourhood(BlockPos position) {
return isWritable(position)
&& isWritable(position.north())
&& isWritable(position.south())
&& isWritable(position.east())
&& isWritable(position.west())
&& isWritable(position.above())
&& isWritable(position.below());
}
private AABB boundedArea(AABB area) {
double minX = Math.max(area.minX, generationBounds.minX);
double minY = Math.max(area.minY, generationBounds.minY);
double minZ = Math.max(area.minZ, generationBounds.minZ);
double maxX = Math.min(area.maxX, generationBounds.maxX);
double maxY = Math.min(area.maxY, generationBounds.maxY);
double maxZ = Math.min(area.maxZ, generationBounds.maxZ);
if (minX >= maxX || minY >= maxY || minZ >= maxZ) {
return null;
}
if (minX == area.minX && minY == area.minY && minZ == area.minZ
&& maxX == area.maxX && maxY == area.maxY && maxZ == area.maxZ) {
return area;
}
return new AABB(minX, minY, minZ, maxX, maxY, maxZ);
}
boolean isInsideGenerationRegion(int chunkX, int chunkZ) {
return Math.abs(chunkX - generationCenter.x()) <= WRITE_RADIUS
&& Math.abs(chunkZ - generationCenter.z()) <= WRITE_RADIUS;
}
@Override
public boolean isOutsideBuildHeight(BlockPos position) {
return position.getY() < getMinY() || position.getY() >= getMaxY();
}
private BlockState terrainState(BlockPos position) {
if (isOutsideBuildHeight(position)) {
return Blocks.VOID_AIR.defaultBlockState();
}
long heights = heights(position.getX(), position.getZ());
int surface = (int) (heights >> 32);
int floor = (int) heights;
if (position.getY() < floor) {
return Blocks.STONE.defaultBlockState();
}
if (position.getY() < surface) {
return Blocks.WATER.defaultBlockState();
}
return Blocks.AIR.defaultBlockState();
}
private int height(Heightmap.Types type, int x, int z) {
long heights = heights(x, z);
if (type == Heightmap.Types.OCEAN_FLOOR || type == Heightmap.Types.OCEAN_FLOOR_WG) {
return (int) heights;
}
return (int) (heights >> 32);
}
private long heights(int x, int z) {
long key = ((long) x << 32) ^ (z & 0xffffffffL);
long cached = terrainHeights.get(key);
if (cached != Long.MIN_VALUE) {
return cached;
}
int floor = Mth.clamp(floorFirstFreeY.applyAsInt(x, z), getMinY(), getMaxY());
int surface = Mth.clamp(surfaceFirstFreeY.applyAsInt(x, z), floor, getMaxY());
long heights = ((long) surface << 32) | (floor & 0xffffffffL);
terrainHeights.put(key, heights);
return heights;
}
private ChunkAccess outsideChunk(int chunkX, int chunkZ) {
long key = ChunkPos.pack(chunkX, chunkZ);
ChunkAccess cached = outsideChunks.get(key);
if (cached != null) {
return cached;
}
EmptyLevelChunk chunk = new EmptyLevelChunk(getLevel(), new ChunkPos(chunkX, chunkZ), fallbackBiome);
WorldgenTerrainHeightmaps.primeTerrain(chunk, surfaceFirstFreeY, floorFirstFreeY);
outsideChunks.put(key, chunk);
return chunk;
}
private record Boundary(ChunkPos generationCenter,
IntBinaryOperator surfaceFirstFreeY,
IntBinaryOperator floorFirstFreeY) {
}
private static final class BoundedTickAccess<T> implements LevelTickAccess<T> {
private final LevelTickAccess<T> delegate;
private final Predicate<BlockPos> writable;
private BoundedTickAccess(LevelTickAccess<T> delegate, Predicate<BlockPos> writable) {
this.delegate = delegate;
this.writable = writable;
}
@Override
public void schedule(ScheduledTick<T> tick) {
if (writable.test(tick.pos())) {
delegate.schedule(tick);
}
}
@Override
public boolean hasScheduledTick(BlockPos position, T type) {
return writable.test(position) && delegate.hasScheduledTick(position, type);
}
@Override
public int count() {
return delegate.count();
}
@Override
public boolean willTickThisTick(BlockPos position, T type) {
return writable.test(position) && delegate.willTickThisTick(position, type);
}
}
}
@@ -0,0 +1,300 @@
package art.arcane.iris.core.nms.v26_2_R1;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.Holder;
import net.minecraft.server.Bootstrap;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.biome.BiomeManager;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import net.minecraft.world.level.entity.EntityTypeTest;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.material.Fluid;
import net.minecraft.world.level.material.FluidState;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.Vec3;
import net.minecraft.world.ticks.LevelTickAccess;
import net.minecraft.world.ticks.ScheduledTick;
import org.junit.BeforeClass;
import org.junit.Test;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public class NativeStructureWorldgenAccessTest {
private static final int GENERATION_CENTER_X = 60;
private static final int GENERATION_CENTER_Z = 15;
private static final int SURFACE_FIRST_FREE_Y = 80;
private static final int FLOOR_FIRST_FREE_Y = 70;
@BeforeClass
public static void bootstrapMinecraftRegistries() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void distanceTwoTerrainReadsNeverReachTheDelegate() {
RecordingDelegate recording = new RecordingDelegate();
NativeStructureWorldgenAccess access = access(recording);
ChunkPos generationCenter = generationCenter();
int x = generationCenter.getMiddleBlockX();
int z = (generationCenter.z() + 2) << 4;
assertSame(Blocks.STONE, access.getBlockState(new BlockPos(x, FLOOR_FIRST_FREE_Y - 1, z)).getBlock());
assertSame(Blocks.WATER, access.getBlockState(new BlockPos(x, FLOOR_FIRST_FREE_Y, z)).getBlock());
assertSame(Blocks.AIR, access.getBlockState(new BlockPos(x, SURFACE_FIRST_FREE_Y, z)).getBlock());
assertSame(Blocks.WATER.defaultBlockState().getFluidState(),
access.getFluidState(new BlockPos(x, FLOOR_FIRST_FREE_Y, z)));
assertEquals(SURFACE_FIRST_FREE_Y, access.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z));
assertEquals(FLOOR_FIRST_FREE_Y, access.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, x, z));
assertNull(access.getBlockStateIfLoaded(new BlockPos(x, FLOOR_FIRST_FREE_Y, z)));
assertNull(access.getChunk(generationCenter.x(), generationCenter.z() + 2, ChunkStatus.FEATURES, false));
assertNull(access.getChunkIfLoadedImmediately(
generationCenter.x(), generationCenter.z() + 2));
List<BlockState> streamed = access.getBlockStates(new AABB(
x, FLOOR_FIRST_FREE_Y, z, x, FLOOR_FIRST_FREE_Y, z)).toList();
assertEquals(1, streamed.size());
assertSame(Blocks.WATER, streamed.getFirst().getBlock());
assertEquals(0, recording.terrainReads);
assertEquals(0, recording.heightReads);
assertEquals(0, recording.chunkReads);
}
@Test
public void distanceTwoMutationsAndSideEffectsNeverReachTheDelegate() {
RecordingDelegate recording = new RecordingDelegate();
NativeStructureWorldgenAccess access = access(recording);
ChunkPos generationCenter = generationCenter();
BlockPos position = new BlockPos(
generationCenter.getMiddleBlockX(),
FLOOR_FIRST_FREE_Y,
(generationCenter.z() + 2) << 4);
assertFalse(access.ensureCanWrite(position));
assertFalse(access.setBlock(position, Blocks.DIRT.defaultBlockState(), 2));
assertFalse(access.removeBlock(position, false));
assertFalse(access.destroyBlock(position, false));
access.updateNeighborsAt(position, Blocks.DIRT);
access.neighborShapeChanged(
Direction.NORTH, position, position.north(),
Blocks.DIRT.defaultBlockState(), 2, 512);
access.levelEvent(null, 2001, position, 0);
access.gameEvent(null, Vec3.atCenterOf(position), null);
access.addParticle(null, position.getX(), position.getY(), position.getZ(), 0, 0, 0);
access.getBlockTicks().schedule(new ScheduledTick<>(
Blocks.DIRT, position, 1L, 0L));
assertEquals(0, recording.mutations);
assertEquals(0, recording.events);
assertEquals(0, recording.blockTicks.count());
}
@Test
public void radiusOneReadsWritesAndTicksRemainUnchanged() {
RecordingDelegate recording = new RecordingDelegate();
NativeStructureWorldgenAccess access = access(recording);
ChunkPos generationCenter = generationCenter();
BlockPos position = new BlockPos(
(generationCenter.x() + 1) << 4,
FLOOR_FIRST_FREE_Y,
(generationCenter.z() - 1) << 4);
assertTrue(access.isInsideGenerationRegion(
generationCenter.x() + 1, generationCenter.z() - 1));
assertSame(Blocks.DIRT, access.getBlockState(position).getBlock());
assertEquals(91, access.getHeight(
Heightmap.Types.WORLD_SURFACE_WG, position.getX(), position.getZ()));
assertTrue(access.ensureCanWrite(position));
assertTrue(access.setBlock(position, Blocks.STONE.defaultBlockState(), 2));
assertTrue(access.removeBlock(position, false));
assertTrue(access.destroyBlock(position, false));
access.getBlockTicks().schedule(new ScheduledTick<>(
Blocks.DIRT, position, 1L, 0L));
assertEquals(1, recording.terrainReads);
assertEquals(1, recording.heightReads);
assertEquals(4, recording.mutations);
assertEquals(1, recording.blockTicks.count());
}
@Test
public void entityQueriesRejectDisjointAreasAndClampOverlaps() {
RecordingDelegate recording = new RecordingDelegate();
NativeStructureWorldgenAccess access = access(recording);
ChunkPos generationCenter = generationCenter();
int safeMaxX = generationCenter.getMaxBlockX() + 17;
int safeMinZ = generationCenter.getMinBlockZ() - 16;
int safeMaxZ = generationCenter.getMaxBlockZ() + 17;
AABB disjoint = new AABB(
safeMaxX, -64, safeMinZ,
safeMaxX + 16, 320, safeMaxZ);
AABB overlap = new AABB(
safeMaxX - 8, -128, safeMinZ - 8,
safeMaxX + 16, 400, safeMaxZ + 8);
EntityTypeTest<Entity, Entity> type = EntityTypeTest.forClass(Entity.class);
assertTrue(access.getEntities((Entity) null, disjoint, entity -> true).isEmpty());
assertTrue(access.getEntities(type, disjoint, entity -> true).isEmpty());
access.getEntities((Entity) null, overlap, entity -> true);
access.getEntities(type, overlap, entity -> true);
assertEquals(2, recording.entityAreas.size());
for (AABB delegatedArea : recording.entityAreas) {
assertEquals(safeMaxX - 8, delegatedArea.minX, 0D);
assertEquals(-64, delegatedArea.minY, 0D);
assertEquals(safeMinZ, delegatedArea.minZ, 0D);
assertEquals(safeMaxX, delegatedArea.maxX, 0D);
assertEquals(320, delegatedArea.maxY, 0D);
assertEquals(safeMaxZ, delegatedArea.maxZ, 0D);
}
}
private static NativeStructureWorldgenAccess access(RecordingDelegate recording) {
return NativeStructureWorldgenAccess.create(
recording.world(), generationCenter(),
(x, z) -> SURFACE_FIRST_FREE_Y,
(x, z) -> FLOOR_FIRST_FREE_Y);
}
private static ChunkPos generationCenter() {
return new ChunkPos(GENERATION_CENTER_X, GENERATION_CENTER_Z);
}
private static final class RecordingDelegate implements InvocationHandler {
private final Holder<Biome> biome;
private final BiomeManager biomeManager;
private final RecordingTicks<Block> blockTicks;
private final RecordingTicks<Fluid> fluidTicks;
private final List<AABB> entityAreas;
private int terrainReads;
private int heightReads;
private int chunkReads;
private int mutations;
private int events;
private RecordingDelegate() {
this.biome = Holder.direct((Biome) null);
this.biomeManager = new BiomeManager((x, y, z) -> biome, 13L);
this.blockTicks = new RecordingTicks<>();
this.fluidTicks = new RecordingTicks<>();
this.entityAreas = new ArrayList<>();
}
private WorldGenLevel world() {
return (WorldGenLevel) Proxy.newProxyInstance(
WorldGenLevel.class.getClassLoader(),
new Class<?>[]{WorldGenLevel.class}, this);
}
@Override
public Object invoke(Object proxy, Method method, Object[] arguments) {
String name = method.getName();
if (name.equals("getSeaLevel")) {
return 63;
}
if (name.equals("getBiome")) {
return biome;
}
if (name.equals("getBiomeManager")) {
return biomeManager;
}
if (name.equals("getBlockTicks")) {
return blockTicks;
}
if (name.equals("getFluidTicks")) {
return fluidTicks;
}
if (name.equals("getMinY")) {
return -64;
}
if (name.equals("getHeight") && arguments == null) {
return 384;
}
if (name.equals("getHeight")) {
heightReads++;
return 91;
}
if (name.equals("getBlockState")) {
terrainReads++;
return Blocks.DIRT.defaultBlockState();
}
if (name.equals("getFluidState")) {
terrainReads++;
return Blocks.WATER.defaultBlockState().getFluidState();
}
if (name.equals("getChunk") || name.equals("getChunkIfLoadedImmediately")) {
chunkReads++;
return null;
}
if (name.equals("getEntities")) {
entityAreas.add((AABB) arguments[1]);
return List.of();
}
if (name.equals("ensureCanWrite") || name.equals("setBlock")
|| name.equals("removeBlock") || name.equals("destroyBlock")) {
mutations++;
return true;
}
if (name.equals("updateNeighborsAt") || name.equals("neighborShapeChanged")) {
mutations++;
return null;
}
if (name.equals("levelEvent") || name.equals("gameEvent") || name.equals("addParticle")) {
events++;
return null;
}
if (name.equals("hashCode")) {
return System.identityHashCode(proxy);
}
if (name.equals("equals")) {
return proxy == arguments[0];
}
if (name.equals("toString")) {
return "native-structure-worldgen-test-delegate";
}
throw new UnsupportedOperationException(method.toString());
}
}
private static final class RecordingTicks<T> implements LevelTickAccess<T> {
private int count;
@Override
public void schedule(ScheduledTick<T> tick) {
count++;
}
@Override
public boolean hasScheduledTick(BlockPos position, T type) {
return false;
}
@Override
public int count() {
return count;
}
@Override
public boolean willTickThisTick(BlockPos position, T type) {
return false;
}
}
}
@@ -130,7 +130,7 @@ public class WorldgenTerrainHeightmapsTest {
int canopySnap = IglooPieces.GENERATION_HEIGHT + iglooSnapOffset(world);
WorldgenTerrainHeightmaps.primeStructurePlacement(
world, List.of(start), surfaceFirstFreeY(), floorFirstFreeY());
world, new ChunkPos(0, 0), List.of(start), surfaceFirstFreeY(), floorFirstFreeY());
int terrainSnap = IglooPieces.GENERATION_HEIGHT + iglooSnapOffset(world);
assertEquals(LAND_TERRAIN_TOP + 1, world.getHeight(
@@ -151,7 +151,8 @@ public class WorldgenTerrainHeightmapsTest {
WorldGenLevel world = world(chunks);
WorldgenTerrainHeightmaps.primeStructurePlacement(
world, List.of(startInOrigin()), surfaceFirstFreeY(), floorFirstFreeY());
world, new ChunkPos(0, 0), List.of(startInOrigin()),
surfaceFirstFreeY(), floorFirstFreeY());
for (ChunkAccess chunk : chunks.values()) {
assertTrue(chunk.getPos().toString(),
@@ -163,6 +164,28 @@ public class WorldgenTerrainHeightmapsTest {
}
}
@Test
public void structurePlacementDoesNotPrimeALoadedDistanceTwoChunk() {
Map<Long, ChunkAccess> chunks = new HashMap<>();
ProtoChunk origin = terrainChunk(new ChunkPos(0, 0));
ProtoChunk neighbour = terrainChunk(new ChunkPos(1, 0));
ProtoChunk distanceTwo = terrainChunk(new ChunkPos(2, 0));
chunks.put(ChunkPos.pack(0, 0), origin);
chunks.put(ChunkPos.pack(1, 0), neighbour);
chunks.put(ChunkPos.pack(2, 0), distanceTwo);
WorldGenLevel world = world(chunks);
StructureStart shifted = startInOrigin();
shifted.getPieces().forEach(piece -> piece.move(16, 0, 0));
WorldgenTerrainHeightmaps.primeStructurePlacement(
world, new ChunkPos(0, 0), List.of(shifted),
surfaceFirstFreeY(), floorFirstFreeY());
assertTrue(origin.hasPrimedHeightmap(Heightmap.Types.WORLD_SURFACE_WG));
assertTrue(neighbour.hasPrimedHeightmap(Heightmap.Types.WORLD_SURFACE_WG));
assertFalse(distanceTwo.hasPrimedHeightmap(Heightmap.Types.WORLD_SURFACE_WG));
}
@Test
public void structurePlacementSkipsChunksOutsideTheGenerationRegion() {
Map<Long, ChunkAccess> chunks = new HashMap<>();
@@ -171,7 +194,8 @@ public class WorldgenTerrainHeightmapsTest {
WorldGenLevel world = world(chunks);
WorldgenTerrainHeightmaps.primeStructurePlacement(
world, List.of(startInOrigin()), surfaceFirstFreeY(), floorFirstFreeY());
world, new ChunkPos(0, 0), List.of(startInOrigin()),
surfaceFirstFreeY(), floorFirstFreeY());
assertTrue(origin.hasPrimedHeightmap(Heightmap.Types.WORLD_SURFACE_WG));
assertEquals(LAND_TERRAIN_TOP, origin.getHeight(
@@ -647,9 +647,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
J.s(() -> {
pendingWorldReplacements.captureVanillaLevelContext();
// Off-main: the verify body takes the replacement-manager monitor and SHA-hashes
// whole pack trees; neither belongs on the tick thread.
J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds);
pendingWorldReplacements.verifyLoadedPublishedWorlds();
J.a(this::bstats);
J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60);
J.sr(this::tickQueue, 0);
@@ -40,6 +40,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -50,6 +51,7 @@ import java.util.concurrent.TimeoutException;
public final class PendingWorldReplacementManager implements Listener {
private final Iris plugin;
private final Set<UUID> cleanupInFlight = new HashSet<>();
private final Set<UUID> verificationInFlight = new HashSet<>();
public PendingWorldReplacementManager(Iris plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin");
@@ -67,12 +69,16 @@ public final class PendingWorldReplacementManager implements Listener {
public synchronized StagedReplacement stageReplacement(
VolmitSender sender,
NamespacedKey worldKey,
IrisDimension dimension
IrisDimension dimension,
Long requestedSeed
) throws IOException {
VolmitSender requiredSender = Objects.requireNonNull(sender, "sender");
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
WorldSlotKey requiredWorldSlotKey = toWorldSlotKey(requiredWorldKey);
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
OptionalLong seedSelection = requestedSeed == null
? OptionalLong.empty()
: OptionalLong.of(requestedSeed.longValue());
IrisStartupValidation.requireWorldReplacementStagingReady();
if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) {
throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap.");
@@ -93,7 +99,6 @@ public final class PendingWorldReplacementManager implements Listener {
UUID transactionId = UUID.randomUUID();
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
WorldReplacementFilesystem.requireExistingTarget(paths);
long effectiveSeed = WorldReplacementSeed.readAuthoritativeSeed(paths.target());
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey);
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
if (!datapacks.succeeded()) {
@@ -119,6 +124,11 @@ public final class PendingWorldReplacementManager implements Listener {
throw new IOException("Iris could not stage the dimension pack.");
}
requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment());
long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed(
paths.target(),
paths.stage(),
seedSelection
);
File stagedPack = paths.stage().resolve("iris/pack").toFile();
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
@@ -227,68 +237,160 @@ public final class PendingWorldReplacementManager implements Listener {
}
}
public synchronized void verifyLoadedPublishedWorlds() {
public void verifyLoadedPublishedWorlds() {
J.a(this::discoverLoadedPublishedWorlds);
}
private void discoverLoadedPublishedWorlds() {
List<Transaction> transactions;
try {
for (Transaction transaction : loadTransactions()) {
if (transaction.phase() == Phase.CLEANUP_PENDING) {
scheduleCommittedCleanup(transaction);
} else if (transaction.phase() == Phase.PUBLISHED) {
WorldIdentity.resolve(toNamespacedKey(transaction.worldKey()))
.ifPresent(world -> verifyPublishedWorld(world, transaction));
}
synchronized (this) {
transactions = loadTransactions();
}
} catch (Throwable failure) {
Iris.reportError("Failed to inspect published Iris world replacements.", failure);
return;
}
for (Transaction transaction : transactions) {
if (transaction.phase() == Phase.CLEANUP_PENDING) {
scheduleCommittedCleanup(transaction);
} else if (transaction.phase() == Phase.PUBLISHED) {
scheduleRuntimeCapture(transaction, 0);
}
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldLoad(WorldLoadEvent event) {
World world = event.getWorld();
// One tick to let the load settle, then verify off-main: the body takes the manager
// monitor (held across pack staging by async threads) and SHA-hashes the whole pack
// tree — blocking the main thread on either froze the server.
J.s(() -> J.a(() -> verifyPublishedWorldIfPending(world)), 1);
WorldSlotKey worldKey;
try {
worldKey = toWorldSlotKey(WorldIdentity.key(event.getWorld()));
} catch (Throwable failure) {
Iris.reportError("Failed to capture a loaded world identity for replacement verification.", failure);
return;
}
J.a(() -> discoverLoadedWorldTransaction(worldKey));
}
private synchronized void verifyPublishedWorldIfPending(World world) {
private void discoverLoadedWorldTransaction(WorldSlotKey worldKey) {
Transaction transaction;
try {
Transaction transaction = findTransaction(WorldIdentity.key(world));
if (transaction != null && transaction.phase() == Phase.PUBLISHED) {
verifyPublishedWorld(world, transaction);
} else if (transaction != null && transaction.phase() == Phase.CLEANUP_PENDING) {
scheduleCommittedCleanup(transaction);
synchronized (this) {
transaction = findTransaction(worldKey);
}
} catch (Throwable failure) {
Iris.reportError("Failed to verify a published Iris world replacement.", failure);
Iris.reportError("Failed to inspect a loaded Iris world replacement.", failure);
return;
}
if (transaction == null) {
return;
}
if (transaction.phase() == Phase.PUBLISHED) {
scheduleRuntimeCapture(transaction, 1);
} else if (transaction.phase() == Phase.CLEANUP_PENDING) {
scheduleCommittedCleanup(transaction);
}
}
private void verifyPublishedWorld(World world, Transaction transaction) {
private void scheduleRuntimeCapture(Transaction transaction, int delayTicks) {
synchronized (this) {
if (!verificationInFlight.add(transaction.id())) {
return;
}
}
try {
J.s(() -> captureLoadedPublishedWorldOnGlobal(transaction), delayTicks);
} catch (Throwable failure) {
finishRuntimeVerification(transaction.id());
Iris.reportError("Could not schedule runtime verification for " + transaction.worldKey() + ".", failure);
}
}
private void captureLoadedPublishedWorldOnGlobal(Transaction transaction) {
World world;
try {
world = WorldIdentity.resolve(toNamespacedKey(transaction.worldKey())).orElse(null);
} catch (Throwable failure) {
dispatchRuntimeCaptureFailure(transaction, failure);
return;
}
if (world == null) {
finishRuntimeVerification(transaction.id());
return;
}
PublishedWorldRuntimeState runtimeState;
try {
runtimeState = capturePublishedWorldRuntime(world);
} catch (Throwable failure) {
dispatchRuntimeCaptureFailure(transaction, failure);
return;
}
try {
J.a(() -> runPublishedWorldVerification(runtimeState, transaction));
} catch (Throwable failure) {
finishRuntimeVerification(transaction.id());
Iris.reportError("Could not dispatch runtime verification for " + transaction.worldKey() + ".", failure);
}
}
private void dispatchRuntimeCaptureFailure(Transaction transaction, Throwable failure) {
try {
J.a(() -> runPublishedWorldCaptureFailure(transaction, failure));
} catch (Throwable dispatchFailure) {
finishRuntimeVerification(transaction.id());
dispatchFailure.addSuppressed(failure);
Iris.reportError("Could not dispatch a failed runtime capture for "
+ transaction.worldKey() + ".", dispatchFailure);
}
}
private void runPublishedWorldCaptureFailure(Transaction transaction, Throwable failure) {
try {
initiateRollback(transaction, failure);
} finally {
finishRuntimeVerification(transaction.id());
}
}
private void runPublishedWorldVerification(
PublishedWorldRuntimeState runtimeState,
Transaction transaction
) {
try {
verifyPublishedWorld(runtimeState, transaction);
} finally {
finishRuntimeVerification(transaction.id());
}
}
static PublishedWorldRuntimeState capturePublishedWorldRuntime(World world) {
World requiredWorld = Objects.requireNonNull(world, "world");
WorldSlotKey worldKey = toWorldSlotKey(WorldIdentity.key(requiredWorld));
boolean irisWorld = IrisToolbelt.isIrisWorld(requiredWorld);
long seed = requiredWorld.getSeed();
World.Environment bukkitEnvironment = requiredWorld.getEnvironment();
PlatformChunkGenerator generator = irisWorld ? IrisToolbelt.access(requiredWorld) : null;
String dimension = null;
IrisEnvironment dimensionEnvironment = null;
if (generator != null) {
IrisDimension runtimeDimension = generator.getTarget().getDimension();
dimension = runtimeDimension.getLoadKey();
dimensionEnvironment = runtimeDimension.getEnvironment();
}
return new PublishedWorldRuntimeState(
worldKey,
irisWorld,
seed,
bukkitEnvironment,
dimension,
dimensionEnvironment
);
}
private void verifyPublishedWorld(PublishedWorldRuntimeState runtimeState, Transaction transaction) {
try {
if (!transaction.worldKey().equals(toWorldSlotKey(WorldIdentity.key(world)))) {
throw new IOException("Loaded world identity does not match the replacement journal.");
}
if (!IrisToolbelt.isIrisWorld(world)) {
throw new IOException("The replaced world did not load with an Iris generator.");
}
if (world.getSeed() != transaction.seed()) {
throw new IOException("The replaced world loaded with an unexpected seed.");
}
World.Environment expectedEnvironment = expectedEnvironment(transaction.worldKey());
if (expectedEnvironment != null && world.getEnvironment() != expectedEnvironment) {
throw new IOException("The replaced world loaded with an unexpected environment.");
}
PlatformChunkGenerator generator = IrisToolbelt.access(world);
if (generator == null || !transaction.dimension().equals(
generator.getTarget().getDimension().getLoadKey())) {
throw new IOException("The replaced world loaded an unexpected Iris dimension.");
}
ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(transaction);
requireCompatibleEnvironment(
target.slotKind(),
generator.getTarget().getDimension().getEnvironment()
);
validatePublishedWorldRuntime(runtimeState, transaction, target.slotKind());
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id());
String fingerprint = WorldReplacementFilesystem.fingerprintPack(
paths.target().resolve("iris/pack"));
@@ -318,6 +420,39 @@ public final class PendingWorldReplacementManager implements Listener {
}
}
static void validatePublishedWorldRuntime(
PublishedWorldRuntimeState runtimeState,
Transaction transaction,
SlotKind slotKind
) throws IOException {
PublishedWorldRuntimeState requiredRuntimeState = Objects.requireNonNull(runtimeState, "runtimeState");
Transaction requiredTransaction = Objects.requireNonNull(transaction, "transaction");
SlotKind requiredSlotKind = Objects.requireNonNull(slotKind, "slotKind");
if (!requiredTransaction.worldKey().equals(requiredRuntimeState.worldKey())) {
throw new IOException("Loaded world identity does not match the replacement journal.");
}
if (!requiredRuntimeState.irisWorld()) {
throw new IOException("The replaced world did not load with an Iris generator.");
}
if (requiredRuntimeState.seed() != requiredTransaction.seed()) {
throw new IOException("The replaced world loaded with an unexpected seed.");
}
World.Environment expectedEnvironment = expectedEnvironment(requiredTransaction.worldKey());
if (expectedEnvironment != null && requiredRuntimeState.bukkitEnvironment() != expectedEnvironment) {
throw new IOException("The replaced world loaded with an unexpected environment.");
}
if (requiredRuntimeState.dimension() == null
|| requiredRuntimeState.dimensionEnvironment() == null
|| !requiredTransaction.dimension().equals(requiredRuntimeState.dimension())) {
throw new IOException("The replaced world loaded an unexpected Iris dimension.");
}
requireCompatibleEnvironment(requiredSlotKind, requiredRuntimeState.dimensionEnvironment());
}
private synchronized void finishRuntimeVerification(UUID transactionId) {
verificationInFlight.remove(transactionId);
}
private void initiateRollback(Transaction transaction, Throwable failure) {
Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey()
+ "; the retained world will be restored on restart.", failure);
@@ -608,4 +743,18 @@ public final class PendingWorldReplacementManager implements Listener {
super(message);
}
}
record PublishedWorldRuntimeState(
WorldSlotKey worldKey,
boolean irisWorld,
long seed,
World.Environment bukkitEnvironment,
String dimension,
IrisEnvironment dimensionEnvironment
) {
PublishedWorldRuntimeState {
Objects.requireNonNull(worldKey, "worldKey");
Objects.requireNonNull(bukkitEnvironment, "bukkitEnvironment");
}
}
}
@@ -79,12 +79,14 @@ import static org.bukkit.Bukkit.getServer;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.IrisMessages;
import art.arcane.iris.core.localization.PackDownloadMessages;
import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
import art.arcane.iris.core.localization.RuntimeUiMessages;
@Director(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command", descriptionKey = "iris.director.commandiris.director.basic_command")
public class CommandIris implements DirectorExecutor {
private static final String NO_DOWNLOAD_SOURCE = "__none__";
private static final String PRESERVE_REPLACEMENT_SEED = "preserve";
private static final long WORLD_UNLOAD_TIMEOUT_SECONDS = 150L;
private CommandStudio studio;
@@ -202,7 +204,15 @@ public class CommandIris implements DirectorExecutor {
defaultValue = "default",
customHandler = PackDimensionTypeHandler.class
)
String type
String type,
@Param(
name = "seed",
aliases = "s",
description = "The replacement seed; omit to preserve the target world's seed",
defaultValue = PRESERVE_REPLACEMENT_SEED,
customHandler = ReplacementSeedHandler.class
)
Long seed
) {
NamespacedKey worldKey;
try {
@@ -227,9 +237,13 @@ public class CommandIris implements DirectorExecutor {
try {
PendingWorldReplacementManager.StagedReplacement staged = Iris.instance
.pendingWorldReplacements()
.stageReplacement(sender(), worldKey, dimension);
.stageReplacement(sender(), worldKey, dimension, seed);
String seedDetail = seed == null
? " preserving seed " + staged.seed()
: " using seed " + staged.seed();
sender().sendMessage(C.GREEN + "Staged Iris replacement for " + staged.worldKey()
+ ". Restart once to publish it. The current dimension is retained until Iris verifies the replacement.");
+ seedDetail + ". Restart once to publish it. The current dimension is retained until Iris "
+ "verifies the replacement.");
} catch (Throwable failure) {
Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure);
String detail = failure.getMessage() == null || failure.getMessage().isBlank()
@@ -539,19 +553,17 @@ public class CommandIris implements DirectorExecutor {
String builtInPack = NO_DOWNLOAD_SOURCE.equals(pack) ? null : pack;
String directLink = NO_DOWNLOAD_SOURCE.equals(link) ? null : link;
if ((builtInPack == null) == (directLink == null)) {
sender().sendMessage("Use exactly one source: /iris download pack=overworld, /iris download pack=underworld, or /iris download link=<zip-url>.");
sender().sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_SOURCE));
return;
}
if (builtInPack != null) {
sender().sendMessage("Downloading built-in Iris pack '" + builtInPack + "' from its beta release.");
Iris.service(StudioSVC.class).downloadBuiltIn(sender(), builtInPack);
return;
}
if (!PackDownloader.isDirectZipUrl(directLink)) {
sender().sendMessage("Iris requires link= to contain a valid HTTP or HTTPS .zip URL.");
sender().sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_URL));
return;
}
sender().sendMessage("Downloading Iris pack from " + directLink + ".");
Iris.service(StudioSVC.class).downloadUrl(sender(), directLink);
}
@@ -894,6 +906,41 @@ public class CommandIris implements DirectorExecutor {
}
}
public static class ReplacementSeedHandler implements DirectorParameterHandler<Long> {
@Override
public KList<Long> getPossibilities() {
return null;
}
@Override
public String toString(Long value) {
return value == null ? PRESERVE_REPLACEMENT_SEED : Long.toString(value);
}
@Override
public Long parse(String in, boolean force) throws DirectorParsingException {
String seed = in == null ? "" : in.trim();
if (PRESERVE_REPLACEMENT_SEED.equalsIgnoreCase(seed)) {
return null;
}
try {
return Long.parseLong(seed);
} catch (NumberFormatException failure) {
throw new DirectorParsingException("Seed must be a signed 64-bit integer");
}
}
@Override
public boolean supports(Class<?> type) {
return type == Long.class;
}
@Override
public String getRandomDefault() {
return "1337";
}
}
public static class DownloadPackHandler implements DirectorParameterHandler<String> {
@Override
public KList<String> getPossibilities() {
@@ -111,7 +111,7 @@ public class PendingWorldReplacementManagerPolicyTest {
IOException failure = assertThrows(
IOException.class,
() -> manager.stageReplacement(sender, NamespacedKey.minecraft("the_nether"), dimension)
() -> manager.stageReplacement(sender, NamespacedKey.minecraft("the_nether"), dimension, null)
);
assertEquals(
@@ -0,0 +1,233 @@
package art.arcane.iris.core;
import art.arcane.iris.core.ExactWorldSlotPathPolicy.SlotKind;
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.framework.EngineTarget;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisEnvironment;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class PendingWorldReplacementThreadAffinityTest {
@Test
public void runtimeStateIsCapturedIntoAnImmutableDetachedSnapshot() {
World world = mock(World.class);
PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
EngineTarget target = mock(EngineTarget.class);
IrisDimension dimension = mock(IrisDimension.class);
when(world.getKey()).thenReturn(NamespacedKey.minecraft("the_nether"));
when(world.getSeed()).thenReturn(-18273645L);
when(world.getEnvironment()).thenReturn(World.Environment.NETHER);
when(generator.getTarget()).thenReturn(target);
when(target.getDimension()).thenReturn(dimension);
when(dimension.getLoadKey()).thenReturn("underworld");
when(dimension.getEnvironment()).thenReturn(IrisEnvironment.NETHER);
PendingWorldReplacementManager.PublishedWorldRuntimeState runtimeState;
try (MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class)) {
toolbelt.when(() -> IrisToolbelt.isIrisWorld(world)).thenReturn(true);
toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator);
runtimeState = PendingWorldReplacementManager.capturePublishedWorldRuntime(world);
}
assertEquals(WorldSlotKey.minecraft("the_nether"), runtimeState.worldKey());
assertTrue(runtimeState.irisWorld());
assertEquals(-18273645L, runtimeState.seed());
assertEquals(World.Environment.NETHER, runtimeState.bukkitEnvironment());
assertEquals("underworld", runtimeState.dimension());
assertEquals(IrisEnvironment.NETHER, runtimeState.dimensionEnvironment());
}
@Test
public void runtimeValidationRejectsIdentitySeedAndEnvironmentMismatches() throws Exception {
Transaction transaction = transaction();
PendingWorldReplacementManager.PublishedWorldRuntimeState valid = runtimeState(
WorldSlotKey.minecraft("the_nether"),
918273645L,
World.Environment.NETHER,
IrisEnvironment.NETHER
);
PendingWorldReplacementManager.validatePublishedWorldRuntime(
valid,
transaction,
SlotKind.VANILLA_NETHER
);
IOException identityFailure = assertThrows(
IOException.class,
() -> PendingWorldReplacementManager.validatePublishedWorldRuntime(
runtimeState(
WorldSlotKey.minecraft("overworld"),
918273645L,
World.Environment.NETHER,
IrisEnvironment.NETHER
),
transaction,
SlotKind.VANILLA_NETHER
)
);
IOException seedFailure = assertThrows(
IOException.class,
() -> PendingWorldReplacementManager.validatePublishedWorldRuntime(
runtimeState(
WorldSlotKey.minecraft("the_nether"),
1L,
World.Environment.NETHER,
IrisEnvironment.NETHER
),
transaction,
SlotKind.VANILLA_NETHER
)
);
IOException bukkitEnvironmentFailure = assertThrows(
IOException.class,
() -> PendingWorldReplacementManager.validatePublishedWorldRuntime(
runtimeState(
WorldSlotKey.minecraft("the_nether"),
918273645L,
World.Environment.NORMAL,
IrisEnvironment.NETHER
),
transaction,
SlotKind.VANILLA_NETHER
)
);
IllegalArgumentException irisEnvironmentFailure = assertThrows(
IllegalArgumentException.class,
() -> PendingWorldReplacementManager.validatePublishedWorldRuntime(
runtimeState(
WorldSlotKey.minecraft("the_nether"),
918273645L,
World.Environment.NETHER,
IrisEnvironment.NORMAL
),
transaction,
SlotKind.VANILLA_NETHER
)
);
assertEquals("Loaded world identity does not match the replacement journal.", identityFailure.getMessage());
assertEquals("The replaced world loaded with an unexpected seed.", seedFailure.getMessage());
assertEquals("The replaced world loaded with an unexpected environment.",
bukkitEnvironmentFailure.getMessage());
assertTrue(irisEnvironmentFailure.getMessage().contains("requires a pack environment of NETHER"));
}
@Test
public void bukkitAccessIsConfinedToTheGlobalCaptureStage() throws Exception {
String managerSource = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/PendingWorldReplacementManager.java"));
String irisSource = Files.readString(Path.of("src/main/java/art/arcane/iris/Iris.java"));
String startup = method(managerSource, "public void verifyLoadedPublishedWorlds()");
String worldLoad = method(managerSource, "public void onWorldLoad(WorldLoadEvent event)");
String discovery = method(managerSource, "private void discoverLoadedPublishedWorlds()");
String capture = method(managerSource,
"private void captureLoadedPublishedWorldOnGlobal(Transaction transaction)");
String snapshot = method(managerSource, "static PublishedWorldRuntimeState capturePublishedWorldRuntime(World world)");
String verification = method(managerSource,
"private void verifyPublishedWorld(PublishedWorldRuntimeState runtimeState, Transaction transaction)");
assertTrue(startup.contains("J.a(this::discoverLoadedPublishedWorlds)"));
assertTrue(worldLoad.contains("WorldIdentity.key(event.getWorld())"));
assertTrue(worldLoad.contains("J.a(() -> discoverLoadedWorldTransaction(worldKey))"));
assertTrue(capture.contains("WorldIdentity.resolve("));
assertBefore(capture, "capturePublishedWorldRuntime(world)",
"J.a(() -> runPublishedWorldVerification(runtimeState, transaction))");
assertTrue(snapshot.contains("WorldIdentity.key(requiredWorld)"));
assertTrue(snapshot.contains("IrisToolbelt.isIrisWorld(requiredWorld)"));
assertTrue(snapshot.contains("requiredWorld.getSeed()"));
assertTrue(snapshot.contains("requiredWorld.getEnvironment()"));
assertTrue(snapshot.contains("IrisToolbelt.access(requiredWorld)"));
assertNoBukkitRuntimeAccess(discovery);
assertNoBukkitRuntimeAccess(verification);
assertTrue(verification.contains("WorldReplacementFilesystem.fingerprintPack("));
assertTrue(irisSource.contains("pendingWorldReplacements.verifyLoadedPublishedWorlds();"));
assertFalse(irisSource.contains("J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds)"));
}
private static PendingWorldReplacementManager.PublishedWorldRuntimeState runtimeState(
WorldSlotKey worldKey,
long seed,
World.Environment bukkitEnvironment,
IrisEnvironment irisEnvironment
) {
return new PendingWorldReplacementManager.PublishedWorldRuntimeState(
worldKey,
true,
seed,
bukkitEnvironment,
"underworld",
irisEnvironment
);
}
private static Transaction transaction() {
return new Transaction(
UUID.fromString("2e488654-c259-4587-a7f2-8a053d59b60f"),
WorldSlotKey.minecraft("the_nether"),
"world_nether",
Path.of("build", "replacement-thread-test", "world"),
"underworld",
918273645L,
"fingerprint",
new WorldGeneratorSnapshot(false, false, false, null, false, null),
true,
Phase.PUBLISHED
);
}
private static void assertNoBukkitRuntimeAccess(String source) {
assertFalse(source.contains("WorldIdentity."));
assertFalse(source.contains("IrisToolbelt."));
assertFalse(source.contains("getSeed()"));
assertFalse(source.contains("getEnvironment()"));
assertFalse(source.contains("Bukkit."));
}
private static void assertBefore(String source, String first, String second) {
int firstIndex = source.indexOf(first);
int secondIndex = source.indexOf(second);
assertTrue("Missing source contract token: " + first, firstIndex >= 0);
assertTrue("Missing source contract token: " + second, secondIndex >= 0);
assertTrue(first + " must occur before " + second, firstIndex < secondIndex);
}
private static String method(String source, String signature) {
int start = source.indexOf(signature);
assertTrue("Missing source contract signature: " + signature, start >= 0);
int openBrace = source.indexOf('{', start);
assertTrue("Missing source contract method body: " + signature, openBrace >= 0);
int depth = 0;
for (int index = openBrace; index < source.length(); index++) {
char current = source.charAt(index);
if (current == '{') {
depth++;
} else if (current == '}') {
depth--;
if (depth == 0) {
return source.substring(start, index + 1);
}
}
}
throw new IllegalArgumentException("Unclosed source contract method: " + signature);
}
}
@@ -2,6 +2,7 @@ package art.arcane.iris.core.commands;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
import org.junit.Test;
import java.lang.reflect.Method;
@@ -10,6 +11,8 @@ import java.util.Arrays;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class CommandIrisCreateOverwriteContractTest {
@@ -27,13 +30,20 @@ public class CommandIrisCreateOverwriteContractTest {
}
@Test
public void replaceOwnsOverrideAndOverwriteAliasesWithoutASeed() throws Exception {
Method command = CommandIris.class.getDeclaredMethod("replace", String.class, String.class);
public void replaceOwnsOverrideAndOverwriteAliasesWithOptionalSeed() throws Exception {
Method command = CommandIris.class.getDeclaredMethod(
"replace",
String.class,
String.class,
Long.class
);
Director director = command.getAnnotation(Director.class);
Parameter targetParameter = command.getParameters()[0];
Param target = targetParameter.getAnnotation(Param.class);
Parameter typeParameter = command.getParameters()[1];
Param type = typeParameter.getAnnotation(Param.class);
Parameter seedParameter = command.getParameters()[2];
Param seed = seedParameter.getAnnotation(Param.class);
assertTrue(Arrays.asList(director.aliases()).contains("override"));
assertTrue(Arrays.asList(director.aliases()).contains("overwrite"));
@@ -45,6 +55,20 @@ public class CommandIrisCreateOverwriteContractTest {
assertEquals(director.descriptionKey(), target.descriptionKey());
assertEquals("default", type.defaultValue());
assertEquals(CommandIris.PackDimensionTypeHandler.class, type.customHandler());
assertEquals("seed", seed.name());
assertEquals("preserve", seed.defaultValue());
assertEquals(CommandIris.ReplacementSeedHandler.class, seed.customHandler());
assertEquals(Long.class, seedParameter.getType());
assertFalse(Arrays.stream(command.getParameterTypes()).anyMatch(parameterType -> parameterType == long.class));
}
@Test
public void replacementSeedHandlerPreservesOrParsesTheFullLongRange() throws Exception {
CommandIris.ReplacementSeedHandler handler = new CommandIris.ReplacementSeedHandler();
assertNull(handler.parse("preserve", false));
assertEquals(Long.valueOf(Long.MIN_VALUE), handler.parse(Long.toString(Long.MIN_VALUE), false));
assertEquals(Long.valueOf(Long.MAX_VALUE), handler.parse(Long.toString(Long.MAX_VALUE), false));
assertThrows(DirectorParsingException.class, () -> handler.parse("9223372036854775808", false));
}
}
@@ -5,12 +5,16 @@ import org.junit.Test;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class CommandIrisDownloadContractTest {
@Test
@@ -42,4 +46,37 @@ public class CommandIrisDownloadContractTest {
assertNull(handler.parse("__none__", false));
assertThrows(Exception.class, () -> handler.parse("custom", false));
}
@Test
public void commandDelegatesAcceptedDownloadsWithoutRawPreamble() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/commands/CommandIris.java"
));
String download = method(source, "public void download(");
assertTrue(download.contains("downloadBuiltIn(sender(), builtInPack)"));
assertTrue(download.contains("downloadUrl(sender(), directLink)"));
assertFalse(download.contains("Downloading built-in Iris pack"));
assertFalse(download.contains("Downloading Iris pack from"));
assertFalse(download.contains("sendMessage(directLink"));
}
private static String method(String source, String signature) {
int start = source.indexOf(signature);
assertTrue(start >= 0);
int openBrace = source.indexOf('{', start);
int depth = 0;
for (int index = openBrace; index < source.length(); index++) {
char current = source.charAt(index);
if (current == '{') {
depth++;
} else if (current == '}') {
depth--;
if (depth == 0) {
return source.substring(start, index + 1);
}
}
}
throw new IllegalArgumentException("Unclosed method: " + signature);
}
}