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 { try {
WorldgenTerrainHeightmaps.primeStructurePlacement( WorldgenTerrainHeightmaps.primeStructurePlacement(
world, heightmapStarts, worldgenSurfaceHeight(), worldgenFloorHeight()); world, chunkPos, heightmapStarts, worldgenSurfaceHeight(), worldgenFloorHeight());
} catch (Throwable error) { } catch (Throwable error) {
throw NativeStructureGenerationException.failure( throw NativeStructureGenerationException.failure(
"heightmap priming", nativeStructureBatchContext(placementGroups), "heightmap priming", nativeStructureBatchContext(placementGroups),
@@ -932,9 +932,16 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
private void placeVanillaStructure(WorldGenLevel world, StructureManager structureManager, WorldgenRandom random, private void placeVanillaStructure(WorldGenLevel world, StructureManager structureManager, WorldgenRandom random,
BoundingBox area, ChunkPos chunkPos, String structureId, StructureStart start, BoundingBox area, ChunkPos chunkPos, String structureId, StructureStart start,
IrisNativeStructureDecision decision) { IrisNativeStructureDecision decision) {
NativeStructurePostProcessor.place(world, structureManager, this, random, area, chunkPos, WorldGenLevel boundedWorld = NativeStructureWorldgenAccess.create(
structureId, start, decision, this::resolvePaletteBlock, world, chunkPos, worldgenSurfaceHeight(), worldgenFloorHeight());
(x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()); 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) { 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); int canopySnap = IglooPieces.GENERATION_HEIGHT + iglooSnapOffset(world);
WorldgenTerrainHeightmaps.primeStructurePlacement( 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); int terrainSnap = IglooPieces.GENERATION_HEIGHT + iglooSnapOffset(world);
assertEquals(LAND_TERRAIN_TOP + 1, world.getHeight( assertEquals(LAND_TERRAIN_TOP + 1, world.getHeight(
@@ -151,7 +151,8 @@ public class WorldgenTerrainHeightmapsTest {
WorldGenLevel world = world(chunks); WorldGenLevel world = world(chunks);
WorldgenTerrainHeightmaps.primeStructurePlacement( WorldgenTerrainHeightmaps.primeStructurePlacement(
world, List.of(startInOrigin()), surfaceFirstFreeY(), floorFirstFreeY()); world, new ChunkPos(0, 0), List.of(startInOrigin()),
surfaceFirstFreeY(), floorFirstFreeY());
for (ChunkAccess chunk : chunks.values()) { for (ChunkAccess chunk : chunks.values()) {
assertTrue(chunk.getPos().toString(), 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 @Test
public void structurePlacementSkipsChunksOutsideTheGenerationRegion() { public void structurePlacementSkipsChunksOutsideTheGenerationRegion() {
Map<Long, ChunkAccess> chunks = new HashMap<>(); Map<Long, ChunkAccess> chunks = new HashMap<>();
@@ -171,7 +194,8 @@ public class WorldgenTerrainHeightmapsTest {
WorldGenLevel world = world(chunks); WorldGenLevel world = world(chunks);
WorldgenTerrainHeightmaps.primeStructurePlacement( 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)); assertTrue(origin.hasPrimedHeightmap(Heightmap.Types.WORLD_SURFACE_WG));
assertEquals(LAND_TERRAIN_TOP, origin.getHeight( assertEquals(LAND_TERRAIN_TOP, origin.getHeight(
@@ -647,9 +647,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
J.s(() -> { J.s(() -> {
pendingWorldReplacements.captureVanillaLevelContext(); pendingWorldReplacements.captureVanillaLevelContext();
// Off-main: the verify body takes the replacement-manager monitor and SHA-hashes pendingWorldReplacements.verifyLoadedPublishedWorlds();
// whole pack trees; neither belongs on the tick thread.
J.a(pendingWorldReplacements::verifyLoadedPublishedWorlds);
J.a(this::bstats); J.a(this::bstats);
J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60); J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60);
J.sr(this::tickQueue, 0); J.sr(this::tickQueue, 0);
@@ -40,6 +40,7 @@ import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.OptionalLong;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@@ -50,6 +51,7 @@ import java.util.concurrent.TimeoutException;
public final class PendingWorldReplacementManager implements Listener { public final class PendingWorldReplacementManager implements Listener {
private final Iris plugin; private final Iris plugin;
private final Set<UUID> cleanupInFlight = new HashSet<>(); private final Set<UUID> cleanupInFlight = new HashSet<>();
private final Set<UUID> verificationInFlight = new HashSet<>();
public PendingWorldReplacementManager(Iris plugin) { public PendingWorldReplacementManager(Iris plugin) {
this.plugin = Objects.requireNonNull(plugin, "plugin"); this.plugin = Objects.requireNonNull(plugin, "plugin");
@@ -67,12 +69,16 @@ public final class PendingWorldReplacementManager implements Listener {
public synchronized StagedReplacement stageReplacement( public synchronized StagedReplacement stageReplacement(
VolmitSender sender, VolmitSender sender,
NamespacedKey worldKey, NamespacedKey worldKey,
IrisDimension dimension IrisDimension dimension,
Long requestedSeed
) throws IOException { ) throws IOException {
VolmitSender requiredSender = Objects.requireNonNull(sender, "sender"); VolmitSender requiredSender = Objects.requireNonNull(sender, "sender");
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey"); NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
WorldSlotKey requiredWorldSlotKey = toWorldSlotKey(requiredWorldKey); WorldSlotKey requiredWorldSlotKey = toWorldSlotKey(requiredWorldKey);
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension"); IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
OptionalLong seedSelection = requestedSeed == null
? OptionalLong.empty()
: OptionalLong.of(requestedSeed.longValue());
IrisStartupValidation.requireWorldReplacementStagingReady(); IrisStartupValidation.requireWorldReplacementStagingReady();
if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) { if (!WorldReplacementBootstrapMarker.wasBootstrappedThisProcess()) {
throw new IOException("Exact world replacement requires a full Paper-family startup bootstrap."); 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(); UUID transactionId = UUID.randomUUID();
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId); ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transactionId);
WorldReplacementFilesystem.requireExistingTarget(paths); WorldReplacementFilesystem.requireExistingTarget(paths);
long effectiveSeed = WorldReplacementSeed.readAuthoritativeSeed(paths.target());
String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey); String worldName = WorldReplacementJournal.logicalWorldName(target.levelRoot(), requiredWorldSlotKey);
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true); DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
if (!datapacks.succeeded()) { if (!datapacks.succeeded()) {
@@ -119,6 +124,11 @@ public final class PendingWorldReplacementManager implements Listener {
throw new IOException("Iris could not stage the dimension pack."); throw new IOException("Iris could not stage the dimension pack.");
} }
requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment()); requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment());
long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed(
paths.target(),
paths.stage(),
seedSelection
);
File stagedPack = paths.stage().resolve("iris/pack").toFile(); File stagedPack = paths.stage().resolve("iris/pack").toFile();
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack); IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath()); 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 { try {
for (Transaction transaction : loadTransactions()) { synchronized (this) {
if (transaction.phase() == Phase.CLEANUP_PENDING) { transactions = loadTransactions();
scheduleCommittedCleanup(transaction);
} else if (transaction.phase() == Phase.PUBLISHED) {
WorldIdentity.resolve(toNamespacedKey(transaction.worldKey()))
.ifPresent(world -> verifyPublishedWorld(world, transaction));
}
} }
} catch (Throwable failure) { } catch (Throwable failure) {
Iris.reportError("Failed to inspect published Iris world replacements.", 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) @EventHandler(priority = EventPriority.MONITOR)
public void onWorldLoad(WorldLoadEvent event) { public void onWorldLoad(WorldLoadEvent event) {
World world = event.getWorld(); WorldSlotKey worldKey;
// One tick to let the load settle, then verify off-main: the body takes the manager try {
// monitor (held across pack staging by async threads) and SHA-hashes the whole pack worldKey = toWorldSlotKey(WorldIdentity.key(event.getWorld()));
// tree — blocking the main thread on either froze the server. } catch (Throwable failure) {
J.s(() -> J.a(() -> verifyPublishedWorldIfPending(world)), 1); 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 { try {
Transaction transaction = findTransaction(WorldIdentity.key(world)); synchronized (this) {
if (transaction != null && transaction.phase() == Phase.PUBLISHED) { transaction = findTransaction(worldKey);
verifyPublishedWorld(world, transaction);
} else if (transaction != null && transaction.phase() == Phase.CLEANUP_PENDING) {
scheduleCommittedCleanup(transaction);
} }
} catch (Throwable failure) { } 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 { 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); ExactWorldSlotPathPolicy.Target target = resolveTransactionTarget(transaction);
requireCompatibleEnvironment( validatePublishedWorldRuntime(runtimeState, transaction, target.slotKind());
target.slotKind(),
generator.getTarget().getDimension().getEnvironment()
);
ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id()); ReplacementPaths paths = WorldReplacementFilesystem.paths(target, transaction.id());
String fingerprint = WorldReplacementFilesystem.fingerprintPack( String fingerprint = WorldReplacementFilesystem.fingerprintPack(
paths.target().resolve("iris/pack")); 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) { private void initiateRollback(Transaction transaction, Throwable failure) {
Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey() Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey()
+ "; the retained world will be restored on restart.", failure); + "; the retained world will be restored on restart.", failure);
@@ -608,4 +743,18 @@ public final class PendingWorldReplacementManager implements Listener {
super(message); 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.IrisLanguage;
import art.arcane.iris.core.localization.IrisMessages; import art.arcane.iris.core.localization.IrisMessages;
import art.arcane.iris.core.localization.PackDownloadMessages;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.iris.core.localization.BukkitCommandMessagesExtended; import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
import art.arcane.iris.core.localization.RuntimeUiMessages; import art.arcane.iris.core.localization.RuntimeUiMessages;
@Director(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command", descriptionKey = "iris.director.commandiris.director.basic_command") @Director(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command", descriptionKey = "iris.director.commandiris.director.basic_command")
public class CommandIris implements DirectorExecutor { public class CommandIris implements DirectorExecutor {
private static final String NO_DOWNLOAD_SOURCE = "__none__"; 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 static final long WORLD_UNLOAD_TIMEOUT_SECONDS = 150L;
private CommandStudio studio; private CommandStudio studio;
@@ -202,7 +204,15 @@ public class CommandIris implements DirectorExecutor {
defaultValue = "default", defaultValue = "default",
customHandler = PackDimensionTypeHandler.class 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; NamespacedKey worldKey;
try { try {
@@ -227,9 +237,13 @@ public class CommandIris implements DirectorExecutor {
try { try {
PendingWorldReplacementManager.StagedReplacement staged = Iris.instance PendingWorldReplacementManager.StagedReplacement staged = Iris.instance
.pendingWorldReplacements() .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() 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) { } catch (Throwable failure) {
Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure); Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure);
String detail = failure.getMessage() == null || failure.getMessage().isBlank() 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 builtInPack = NO_DOWNLOAD_SOURCE.equals(pack) ? null : pack;
String directLink = NO_DOWNLOAD_SOURCE.equals(link) ? null : link; String directLink = NO_DOWNLOAD_SOURCE.equals(link) ? null : link;
if ((builtInPack == null) == (directLink == null)) { 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; return;
} }
if (builtInPack != null) { if (builtInPack != null) {
sender().sendMessage("Downloading built-in Iris pack '" + builtInPack + "' from its beta release.");
Iris.service(StudioSVC.class).downloadBuiltIn(sender(), builtInPack); Iris.service(StudioSVC.class).downloadBuiltIn(sender(), builtInPack);
return; return;
} }
if (!PackDownloader.isDirectZipUrl(directLink)) { 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; return;
} }
sender().sendMessage("Downloading Iris pack from " + directLink + ".");
Iris.service(StudioSVC.class).downloadUrl(sender(), 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> { public static class DownloadPackHandler implements DirectorParameterHandler<String> {
@Override @Override
public KList<String> getPossibilities() { public KList<String> getPossibilities() {
@@ -111,7 +111,7 @@ public class PendingWorldReplacementManagerPolicyTest {
IOException failure = assertThrows( IOException failure = assertThrows(
IOException.class, IOException.class,
() -> manager.stageReplacement(sender, NamespacedKey.minecraft("the_nether"), dimension) () -> manager.stageReplacement(sender, NamespacedKey.minecraft("the_nether"), dimension, null)
); );
assertEquals( 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.Director;
import art.arcane.volmlib.util.director.annotations.Param; import art.arcane.volmlib.util.director.annotations.Param;
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
import org.junit.Test; import org.junit.Test;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@@ -10,6 +11,8 @@ import java.util.Arrays;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class CommandIrisCreateOverwriteContractTest { public class CommandIrisCreateOverwriteContractTest {
@@ -27,13 +30,20 @@ public class CommandIrisCreateOverwriteContractTest {
} }
@Test @Test
public void replaceOwnsOverrideAndOverwriteAliasesWithoutASeed() throws Exception { public void replaceOwnsOverrideAndOverwriteAliasesWithOptionalSeed() throws Exception {
Method command = CommandIris.class.getDeclaredMethod("replace", String.class, String.class); Method command = CommandIris.class.getDeclaredMethod(
"replace",
String.class,
String.class,
Long.class
);
Director director = command.getAnnotation(Director.class); Director director = command.getAnnotation(Director.class);
Parameter targetParameter = command.getParameters()[0]; Parameter targetParameter = command.getParameters()[0];
Param target = targetParameter.getAnnotation(Param.class); Param target = targetParameter.getAnnotation(Param.class);
Parameter typeParameter = command.getParameters()[1]; Parameter typeParameter = command.getParameters()[1];
Param type = typeParameter.getAnnotation(Param.class); 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("override"));
assertTrue(Arrays.asList(director.aliases()).contains("overwrite")); assertTrue(Arrays.asList(director.aliases()).contains("overwrite"));
@@ -45,6 +55,20 @@ public class CommandIrisCreateOverwriteContractTest {
assertEquals(director.descriptionKey(), target.descriptionKey()); assertEquals(director.descriptionKey(), target.descriptionKey());
assertEquals("default", type.defaultValue()); assertEquals("default", type.defaultValue());
assertEquals(CommandIris.PackDimensionTypeHandler.class, type.customHandler()); 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)); 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.Method;
import java.lang.reflect.Parameter; import java.lang.reflect.Parameter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class CommandIrisDownloadContractTest { public class CommandIrisDownloadContractTest {
@Test @Test
@@ -42,4 +46,37 @@ public class CommandIrisDownloadContractTest {
assertNull(handler.parse("__none__", false)); assertNull(handler.parse("__none__", false));
assertThrows(Exception.class, () -> handler.parse("custom", 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);
}
} }
@@ -47,11 +47,14 @@ public final class WorldgenTerrainHeightmaps {
write(chunk, Heightmap.Types.OCEAN_FLOOR_WG, floorFirstFreeY); write(chunk, Heightmap.Types.OCEAN_FLOOR_WG, floorFirstFreeY);
} }
public static void primeStructurePlacement(WorldGenLevel world, List<StructureStart> starts, public static void primeStructurePlacement(WorldGenLevel world, ChunkPos generationCenter,
List<StructureStart> starts,
IntBinaryOperator surfaceFirstFreeY, IntBinaryOperator surfaceFirstFreeY,
IntBinaryOperator floorFirstFreeY) { IntBinaryOperator floorFirstFreeY) {
Objects.requireNonNull(world, Objects.requireNonNull(world,
"Iris worldgen heightmap priming requires a generation level"); "Iris worldgen heightmap priming requires a generation level");
Objects.requireNonNull(generationCenter,
"Iris worldgen heightmap priming requires a generation center");
if (starts == null || starts.isEmpty()) { if (starts == null || starts.isEmpty()) {
return; return;
} }
@@ -67,6 +70,10 @@ public final class WorldgenTerrainHeightmaps {
int maxChunkZ = SectionPos.blockToSectionCoord(bounds.maxZ()) + PLACEMENT_CHUNK_MARGIN; int maxChunkZ = SectionPos.blockToSectionCoord(bounds.maxZ()) + PLACEMENT_CHUNK_MARGIN;
for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) { for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) {
for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) { for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) {
if (Math.abs(chunkX - generationCenter.x()) > PLACEMENT_CHUNK_MARGIN
|| Math.abs(chunkZ - generationCenter.z()) > PLACEMENT_CHUNK_MARGIN) {
continue;
}
if (!primed.add(ChunkPos.pack(chunkX, chunkZ))) { if (!primed.add(ChunkPos.pack(chunkX, chunkZ))) {
continue; continue;
} }
@@ -30,6 +30,7 @@ import art.arcane.iris.engine.object.BlockDataMergeSupport;
import art.arcane.iris.engine.object.IrisObjectRotation; import art.arcane.iris.engine.object.IrisObjectRotation;
import art.arcane.iris.engine.object.TileData; import art.arcane.iris.engine.object.TileData;
import art.arcane.iris.modded.api.ModdedCustomContentRegistry; import art.arcane.iris.modded.api.ModdedCustomContentRegistry;
import art.arcane.iris.modded.command.IrisModdedCommands;
import art.arcane.iris.modded.command.ModdedGuiHost; import art.arcane.iris.modded.command.ModdedGuiHost;
import art.arcane.iris.modded.command.ModdedObjectUndo; import art.arcane.iris.modded.command.ModdedObjectUndo;
import art.arcane.iris.modded.command.ModdedPregenBossBar; import art.arcane.iris.modded.command.ModdedPregenBossBar;
@@ -109,6 +110,7 @@ public final class ModdedEngineBootstrap {
if (scheduler != null) { if (scheduler != null) {
scheduler.reset(); scheduler.reset();
} }
IrisModdedCommands.openDownloadAdmission();
ModdedStartup.prepareForStartup(); ModdedStartup.prepareForStartup();
IrisModdedChunkGenerator.startGenPool(); IrisModdedChunkGenerator.startGenPool();
bindWorldGenerators(server); bindWorldGenerators(server);
@@ -169,6 +171,7 @@ public final class ModdedEngineBootstrap {
public static void stop() { public static void stop() {
MinecraftServer stoppingServer = currentServer; MinecraftServer stoppingServer = currentServer;
Throwable failure = null; Throwable failure = null;
failure = runStopStage(failure, "pack downloads", IrisModdedCommands::shutdownDownloads);
failure = runStopStage(failure, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer)); failure = runStopStage(failure, "world check", () -> ModdedWorldCheck.serverStopped(stoppingServer));
failure = runStopStage(failure, "protocol", ModdedProtocolHandler::stop); failure = runStopStage(failure, "protocol", ModdedProtocolHandler::stop);
failure = runStopStage(failure, "pregenerator", ModdedPregenJob::shutdown); failure = runStopStage(failure, "pregenerator", ModdedPregenJob::shutdown);
@@ -349,7 +349,7 @@ final class ModdedNativeStructureStage {
try { try {
int runtimeMinY = world.getMinY(); int runtimeMinY = world.getMinY();
WorldgenTerrainHeightmaps.primeStructurePlacement( WorldgenTerrainHeightmaps.primeStructurePlacement(
world, heightmapStarts, world, chunkPos, heightmapStarts,
worldgenSurfaceHeight(current, runtimeMinY), worldgenSurfaceHeight(current, runtimeMinY),
worldgenFloorHeight(current, runtimeMinY)); worldgenFloorHeight(current, runtimeMinY));
} catch (Throwable error) { } catch (Throwable error) {
@@ -24,14 +24,19 @@ import net.minecraft.server.MinecraftServer;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.BlockingQueue; import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.PriorityBlockingQueue; import java.util.concurrent.PriorityBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.RejectedExecutionHandler; import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
@@ -76,6 +81,9 @@ public final class ModdedScheduler implements PlatformScheduler {
private static RejectedExecutionHandler dropRejectedTask() { private static RejectedExecutionHandler dropRejectedTask() {
return (Runnable task, ThreadPoolExecutor executor) -> { return (Runnable task, ThreadPoolExecutor executor) -> {
if (task instanceof RejectionAwareTask rejectionAwareTask) {
rejectionAwareTask.reject();
}
if (executor.isShutdown()) { if (executor.isShutdown()) {
LOGGER.debug("Iris async task dropped: scheduler is shut down"); LOGGER.debug("Iris async task dropped: scheduler is shut down");
return; return;
@@ -130,6 +138,24 @@ public final class ModdedScheduler implements PlatformScheduler {
executor.execute(() -> runGuarded(task)); executor.execute(() -> runGuarded(task));
} }
public boolean asyncIfRunning(Runnable task, Runnable rejection) {
if (task == null) {
return false;
}
ThreadPoolExecutor executor = asyncExecutor;
RejectionAwareTask submittedTask = new RejectionAwareTask(
() -> runGuarded(task),
Objects.requireNonNull(rejection, "rejection")
);
warnOnBacklog(executor);
try {
executor.execute(submittedTask);
} catch (RejectedExecutionException exception) {
submittedTask.reject();
}
return !submittedTask.isRejected();
}
@Override @Override
public void laterGlobal(Runnable task, int ticks) { public void laterGlobal(Runnable task, int ticks) {
if (task == null) { if (task == null) {
@@ -151,7 +177,9 @@ public final class ModdedScheduler implements PlatformScheduler {
if (asyncExecutor.isShutdown()) { if (asyncExecutor.isShutdown()) {
asyncExecutor = createAsyncExecutor(); asyncExecutor = createAsyncExecutor();
} else { } else {
asyncExecutor.getQueue().clear(); List<Runnable> abandonedTasks = new ArrayList<>();
asyncExecutor.getQueue().drainTo(abandonedTasks);
rejectAbandonedTasks(abandonedTasks);
} }
mainQueue.clear(); mainQueue.clear();
delayedQueue.clear(); delayedQueue.clear();
@@ -162,7 +190,8 @@ public final class ModdedScheduler implements PlatformScheduler {
} }
public void shutdown() { public void shutdown() {
asyncExecutor.shutdownNow(); List<Runnable> abandonedTasks = asyncExecutor.shutdownNow();
rejectAbandonedTasks(abandonedTasks);
mainQueue.clear(); mainQueue.clear();
delayedQueue.clear(); delayedQueue.clear();
mainThread = null; mainThread = null;
@@ -170,6 +199,14 @@ public final class ModdedScheduler implements PlatformScheduler {
ModdedServerLevels.forget(); ModdedServerLevels.forget();
} }
private static void rejectAbandonedTasks(List<Runnable> abandonedTasks) {
for (Runnable abandonedTask : abandonedTasks) {
if (abandonedTask instanceof RejectionAwareTask rejectionAwareTask) {
rejectionAwareTask.reject();
}
}
}
private void warnOnBacklog(ThreadPoolExecutor executor) { private void warnOnBacklog(ThreadPoolExecutor executor) {
int queued = executor.getQueue().size(); int queued = executor.getQueue().size();
if (queued < ASYNC_BACKLOG_WARN) { if (queued < ASYNC_BACKLOG_WARN) {
@@ -247,4 +284,33 @@ public final class ModdedScheduler implements PlatformScheduler {
return thread; return thread;
} }
} }
private static final class RejectionAwareTask implements Runnable {
private final Runnable task;
private final Runnable rejection;
private final AtomicBoolean rejected;
private RejectionAwareTask(Runnable task, Runnable rejection) {
this.task = task;
this.rejection = rejection;
rejected = new AtomicBoolean();
}
@Override
public void run() {
if (!rejected.get()) {
task.run();
}
}
private void reject() {
if (rejected.compareAndSet(false, true)) {
rejection.run();
}
}
private boolean isRejected() {
return rejected.get();
}
}
} }
@@ -19,7 +19,13 @@
package art.arcane.iris.modded.command; package art.arcane.iris.modded.command;
import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.IrisMessages; import art.arcane.iris.core.localization.IrisMessages;
import art.arcane.iris.core.localization.ModdedCommandMessages;
import art.arcane.iris.core.localization.PackDownloadMessages;
import art.arcane.iris.core.localization.RuntimeUiMessages;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.pack.PackDownloadExecution;
import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.IrisModdedChunkGenerator;
@@ -56,15 +62,18 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.TreeMap; import java.util.TreeMap;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.ModdedCommandMessages;
import art.arcane.iris.core.localization.RuntimeUiMessages;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
public final class IrisModdedCommands { public final class IrisModdedCommands {
private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final Logger LOGGER = LoggerFactory.getLogger("Iris");
private static final long DOWNLOAD_SHUTDOWN_POLL_SECONDS = 15L;
private static final Object DOWNLOAD_MONITOR = new Object();
static final SuggestionProvider<CommandSourceStack> PACK_NAMES = ModdedCommandSuggestions.PACK_NAMES; static final SuggestionProvider<CommandSourceStack> PACK_NAMES = ModdedCommandSuggestions.PACK_NAMES;
private static PackDownloadExecution activeDownload;
private static boolean downloadAdmissionOpen;
private IrisModdedCommands() { private IrisModdedCommands() {
} }
@@ -76,6 +85,43 @@ public final class IrisModdedCommands {
IrisLogging.info("Iris /iris command tree registered"); IrisLogging.info("Iris /iris command tree registered");
} }
public static void openDownloadAdmission() {
synchronized (DOWNLOAD_MONITOR) {
downloadAdmissionOpen = true;
}
}
public static void shutdownDownloads() {
PackDownloadExecution execution;
synchronized (DOWNLOAD_MONITOR) {
downloadAdmissionOpen = false;
execution = activeDownload;
}
if (execution == null) {
return;
}
execution.cancel();
boolean interrupted = false;
boolean warned = false;
while (!execution.isComplete()) {
try {
if (!execution.await(DOWNLOAD_SHUTDOWN_POLL_SECONDS, TimeUnit.SECONDS) && !warned) {
warned = true;
LOGGER.warn(execution.isPublishing()
? "Waiting for atomic pack publication to finish before Iris shutdown."
: "Waiting for the active pack download to cancel before Iris shutdown.");
}
} catch (InterruptedException exception) {
interrupted = true;
execution.cancel();
}
}
if (interrupted) {
Thread.currentThread().interrupt();
}
}
static int tp(CommandSourceStack source, ServerLevel level, ServerPlayer target) { static int tp(CommandSourceStack source, ServerLevel level, ServerPlayer target) {
ServerPlayer player = target != null ? target : source.getPlayer(); ServerPlayer player = target != null ? target : source.getPlayer();
if (player == null) { if (player == null) {
@@ -264,9 +310,8 @@ public final class IrisModdedCommands {
fail(source, "Use /iris download pack=overworld, /iris download pack=underworld, or /iris download link=<zip-url>."); fail(source, "Use /iris download pack=overworld, /iris download pack=underworld, or /iris download link=<zip-url>.");
return 0; return 0;
} }
String target = request.pack() == null ? request.url() : request.pack(); String target = downloadDisplayTarget(request);
String downloadSource = request.pack() == null ? "direct ZIP URL" : "built-in beta release"; String downloadSource = request.pack() == null ? "direct ZIP URL" : "built-in beta release";
ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_DOWNLOADING_IRISDIMENSIONS, MessageArgument.untrusted("pack", target), MessageArgument.untrusted("downloadSource", downloadSource)));
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
if (scheduler == null) { if (scheduler == null) {
fail(source, IrisLanguage.plain( fail(source, IrisLanguage.plain(
@@ -275,36 +320,143 @@ public final class IrisModdedCommands {
MessageArgument.untrusted("downloadSource", downloadSource))); MessageArgument.untrusted("downloadSource", downloadSource)));
return 0; return 0;
} }
scheduler.async(() -> { PackDownloadExecution execution;
boolean installed = false; synchronized (DOWNLOAD_MONITOR) {
File packs = ModdedPackCommands.packsRoot(); if (!downloadAdmissionOpen) {
fail(source, IrisLanguage.plain(
ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_DOWNLOAD_FAILED_SEE_CONSOLE,
MessageArgument.untrusted("pack", target),
MessageArgument.untrusted("downloadSource", downloadSource)));
return 0;
}
LifecycleOperationCoordinator.Lease lease;
try { try {
PackDownloader.PackInstallResult result = request.pack() == null lease = LifecycleOperationCoordinator.get().acquire(
? PackDownloader.downloadUrl( LifecycleOperationCoordinator.Domain.PACK_MUTATION,
packs, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD,
request.url(), target
false, );
(String message) -> scheduler.global(() -> ok(source, message)) } catch (LifecycleOperationCoordinator.BusyException error) {
) fail(source, downloadBusyMessage(error.currentOperation()));
: PackDownloader.downloadBuiltIn( return 0;
packs,
request.pack(),
false,
(String message) -> scheduler.global(() -> ok(source, message))
);
installed = result != null;
} catch (IOException | RuntimeException error) {
LOGGER.error("Iris pack download failed for {}", target, error);
} }
if (installed) {
scheduler.global(() -> ok(source, "Pack installed on disk. Restart the server before using it.")); execution = new PackDownloadExecution(
} else { lease,
scheduler.global(() -> fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_DOWNLOAD_FAILED_SEE_CONSOLE, MessageArgument.untrusted("pack", target), MessageArgument.untrusted("downloadSource", downloadSource)))); cancellation -> executeDownload(source, request, target, downloadSource, scheduler, cancellation)
);
PackDownloadExecution trackedExecution = execution;
execution.onCompletion(() -> clearActiveDownload(trackedExecution));
activeDownload = execution;
boolean accepted;
try {
accepted = scheduler.asyncIfRunning(execution, execution::cancel);
} catch (Throwable error) {
execution.cancel();
LOGGER.error("Iris pack download dispatch failed for {}", target, error);
fail(source, IrisLanguage.plain(
ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_DOWNLOAD_FAILED_SEE_CONSOLE,
MessageArgument.untrusted("pack", target),
MessageArgument.untrusted("downloadSource", downloadSource)));
return 0;
} }
}); if (!accepted) {
execution.cancel();
LOGGER.error("Iris pack download dispatch rejected for {} because the scheduler is shut down", target);
fail(source, IrisLanguage.plain(
ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_DOWNLOAD_FAILED_SEE_CONSOLE,
MessageArgument.untrusted("pack", target),
MessageArgument.untrusted("downloadSource", downloadSource)));
return 0;
}
}
ok(source, IrisLanguage.plain(
ModdedCommandMessages.IRIS_MODDED_COMMANDS_DOWNLOADING_IRISDIMENSIONS,
MessageArgument.untrusted("pack", target),
MessageArgument.untrusted("downloadSource", downloadSource)));
return 1; return 1;
} }
private static void executeDownload(
CommandSourceStack source,
DownloadRequest request,
String target,
String downloadSource,
ModdedScheduler scheduler,
PackDownloader.DownloadCancellation cancellation
) throws PackDownloader.PackDownloadCancelledException {
File packs = ModdedPackCommands.packsRoot();
try {
PackDownloader.PackInstallResult result = request.pack() == null
? PackDownloader.downloadUrl(
packs,
request.url(),
false,
(String message) -> scheduler.global(() -> ok(source, message)),
cancellation
)
: PackDownloader.downloadBuiltIn(
packs,
request.pack(),
false,
(String message) -> scheduler.global(() -> ok(source, message)),
cancellation
);
String completionMessage = downloadCompletionMessage(result);
if (result != null) {
if (completionMessage != null) {
scheduler.global(() -> ok(source, completionMessage));
}
return;
}
} catch (PackDownloader.PackDownloadCancelledException error) {
throw error;
} catch (PackDownloader.PackDownloadBusyException error) {
scheduler.global(() -> fail(source, error.getMessage()));
return;
} catch (IOException | RuntimeException error) {
LOGGER.error("Iris pack download failed for {}", target, error);
}
scheduler.global(() -> fail(source, IrisLanguage.plain(
ModdedCommandMessages.IRIS_MODDED_COMMANDS_PACK_DOWNLOAD_FAILED_SEE_CONSOLE,
MessageArgument.untrusted("pack", target),
MessageArgument.untrusted("downloadSource", downloadSource))));
}
static String downloadBusyMessage(LifecycleOperationCoordinator.ActiveOperation operation) {
if (operation.domain() == LifecycleOperationCoordinator.Domain.PACK_MUTATION
&& operation.kind() == LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD) {
return IrisLanguage.plain(PackDownloadMessages.IN_PROGRESS);
}
return "Iris pack changes are busy with " + operation.kind().name().toLowerCase(Locale.ROOT)
+ " for '" + operation.target() + "'. Try again when it completes.";
}
static String downloadCompletionMessage(PackDownloader.PackInstallResult result) {
if (result == null || !result.changed()) {
return null;
}
return result.restartRequired()
? "Pack installed on disk. Restart the server before using it."
: "Pack installed on disk.";
}
static String downloadDisplayTarget(DownloadRequest request) {
return request.pack() == null
? IrisLanguage.plain(PackDownloadMessages.PROGRESS_SOURCE_REMOTE)
: request.pack();
}
private static void clearActiveDownload(PackDownloadExecution execution) {
synchronized (DOWNLOAD_MONITOR) {
if (activeDownload == execution) {
activeDownload = null;
}
}
}
static DownloadRequest parseDownloadRequest(String rawRequest) { static DownloadRequest parseDownloadRequest(String rawRequest) {
if (rawRequest == null || rawRequest.isBlank()) { if (rawRequest == null || rawRequest.isBlank()) {
return null; return null;
@@ -86,6 +86,60 @@ public class ModdedGenerationLeaseContractTest {
assertTrue(source.contains("active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)")); assertTrue(source.contains("active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)"));
} }
@Test
public void packDownloadAdmissionPrecedesAsyncDispatch() throws IOException {
String source = source("art/arcane/iris/modded/command/IrisModdedCommands.java");
String download = method(source, "static int download(CommandSourceStack source, String rawRequest)");
int admission = download.indexOf("LifecycleOperationCoordinator.get().acquire(");
int executionTracking = download.indexOf("new PackDownloadExecution(");
int dispatch = download.indexOf("scheduler.asyncIfRunning(execution, execution::cancel)");
assertTrue(admission >= 0);
assertTrue(executionTracking > admission);
assertTrue(dispatch > admission);
assertTrue(download.contains("execution.cancel();"));
String execution = method(source, "private static void executeDownload(");
assertTrue(execution.contains("PackDownloader.DownloadCancellation cancellation"));
assertTrue(execution.contains("catch (PackDownloader.PackDownloadCancelledException error)"));
assertFalse(execution.contains("lease.close();"));
}
@Test
public void packDownloadsDrainBeforeModdedSchedulerShutdown() throws IOException {
String commands = source("art/arcane/iris/modded/command/IrisModdedCommands.java");
String shutdownDownloads = method(commands, "public static void shutdownDownloads()");
assertTrue(shutdownDownloads.contains("downloadAdmissionOpen = false;"));
assertTrue(shutdownDownloads.contains("execution.cancel();"));
assertTrue(shutdownDownloads.contains("execution.await("));
String bootstrap = source("art/arcane/iris/modded/ModdedEngineBootstrap.java");
String stop = method(bootstrap, "public static void stop()");
int downloads = stop.indexOf("IrisModdedCommands::shutdownDownloads");
int scheduler = stop.indexOf("scheduler::shutdown");
assertTrue(downloads >= 0);
assertTrue(scheduler > downloads);
}
@Test
public void moddedSchedulerRejectsAndCancelsAbandonedDownloadSubmissions() throws IOException {
String scheduler = source("art/arcane/iris/modded/ModdedScheduler.java");
String dispatch = method(scheduler, "public boolean asyncIfRunning(Runnable task, Runnable rejection)");
assertTrue(dispatch.contains("RejectionAwareTask"));
assertTrue(dispatch.contains("Objects.requireNonNull(rejection"));
String shutdown = method(scheduler, "public void shutdown()");
assertTrue(shutdown.contains("asyncExecutor.shutdownNow()"));
assertTrue(shutdown.contains("rejectAbandonedTasks(abandonedTasks);"));
String reset = method(scheduler, "public void reset()");
assertTrue(reset.contains("asyncExecutor.getQueue().drainTo(abandonedTasks);"));
assertTrue(reset.contains("rejectAbandonedTasks(abandonedTasks);"));
String rejection = method(scheduler, "private static void rejectAbandonedTasks(");
assertTrue(rejection.contains("rejectionAwareTask.reject();"));
}
@Test @Test
public void blockingPregenShutdownDefersItsFinalSaveToTheServerThread() throws IOException { public void blockingPregenShutdownDefersItsFinalSaveToTheServerThread() throws IOException {
String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java"); String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java");
@@ -0,0 +1,110 @@
package art.arcane.iris.modded;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.pack.PackDownloadExecution;
import org.junit.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class ModdedSchedulerDownloadCancellationTest {
@Test
public void rejectedDownloadSubmissionReleasesItsLease() throws Exception {
ModdedScheduler scheduler = new ModdedScheduler();
scheduler.shutdown();
TestLease lease = new TestLease();
AtomicBoolean ran = new AtomicBoolean();
PackDownloadExecution execution = new PackDownloadExecution(
lease,
cancellation -> ran.set(true)
);
boolean accepted = scheduler.asyncIfRunning(execution, execution::cancel);
assertFalse(accepted);
assertTrue(execution.await(1L, TimeUnit.SECONDS));
assertFalse(ran.get());
assertEquals(1, lease.closeCount());
}
@Test
public void schedulerShutdownCancelsQueuedDownloadAndReleasesItsLease() throws Exception {
ModdedScheduler scheduler = new ModdedScheduler();
int workerCount = Math.max(4, Runtime.getRuntime().availableProcessors());
CountDownLatch workersStarted = new CountDownLatch(workerCount);
CountDownLatch releaseWorkers = new CountDownLatch(1);
for (int index = 0; index < workerCount; index++) {
scheduler.async(() -> {
workersStarted.countDown();
try {
releaseWorkers.await();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
});
}
assertTrue(workersStarted.await(10L, TimeUnit.SECONDS));
TestLease lease = new TestLease();
AtomicBoolean ran = new AtomicBoolean();
PackDownloadExecution execution = new PackDownloadExecution(
lease,
cancellation -> ran.set(true)
);
assertTrue(scheduler.asyncIfRunning(execution, execution::cancel));
try {
scheduler.shutdown();
assertTrue(execution.await(5L, TimeUnit.SECONDS));
assertFalse(ran.get());
assertEquals(1, lease.closeCount());
} finally {
releaseWorkers.countDown();
scheduler.shutdown();
}
}
private static final class TestLease implements LifecycleOperationCoordinator.Lease {
private final LifecycleOperationCoordinator.ActiveOperation operation;
private final AtomicBoolean closed;
private final AtomicInteger closeCount;
private TestLease() {
operation = new LifecycleOperationCoordinator.ActiveOperation(
1L,
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD,
"test"
);
closed = new AtomicBoolean();
closeCount = new AtomicInteger();
}
@Override
public LifecycleOperationCoordinator.ActiveOperation operation() {
return operation;
}
@Override
public boolean isClosed() {
return closed.get();
}
@Override
public void close() {
closeCount.incrementAndGet();
closed.set(true);
}
private int closeCount() {
return closeCount.get();
}
}
}
@@ -1,5 +1,9 @@
package art.arcane.iris.modded.command; package art.arcane.iris.modded.command;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.PackDownloadMessages;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.pack.PackDownloader;
import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.tree.CommandNode; import com.mojang.brigadier.tree.CommandNode;
import net.minecraft.SharedConstants; import net.minecraft.SharedConstants;
@@ -9,10 +13,11 @@ import org.junit.Test;
import java.util.List; import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class IrisModdedCommandParityTest { public class IrisModdedCommandParityTest {
@@ -73,6 +78,9 @@ public class IrisModdedCommandParityTest {
assertEquals("underworld", underworld.pack()); assertEquals("underworld", underworld.pack());
assertNotNull(link); assertNotNull(link);
assertEquals("https://packs.example.test/custom.zip?token=a=b", link.url()); assertEquals("https://packs.example.test/custom.zip?token=a=b", link.url());
String displayTarget = IrisModdedCommands.downloadDisplayTarget(link);
assertEquals(IrisLanguage.plain(PackDownloadMessages.PROGRESS_SOURCE_REMOTE), displayTarget);
assertFalse(displayTarget.contains("token"));
assertNull(IrisModdedCommands.parseDownloadRequest("overworld")); assertNull(IrisModdedCommands.parseDownloadRequest("overworld"));
assertNull(IrisModdedCommands.parseDownloadRequest("pack=custom")); assertNull(IrisModdedCommands.parseDownloadRequest("pack=custom"));
assertNull(IrisModdedCommands.parseDownloadRequest("link=https://packs.example.test/custom.tar.gz")); assertNull(IrisModdedCommands.parseDownloadRequest("link=https://packs.example.test/custom.tar.gz"));
@@ -80,6 +88,51 @@ public class IrisModdedCommandParityTest {
assertNull(IrisModdedCommands.parseDownloadRequest("pack=underworld overwrite=true")); assertNull(IrisModdedCommands.parseDownloadRequest("pack=underworld overwrite=true"));
} }
@Test
public void downloadCompletionMessageOnlyReportsActualPackChanges() {
assertEquals(
"Pack installed on disk. Restart the server before using it.",
IrisModdedCommands.downloadCompletionMessage(
new PackDownloader.PackInstallResult("overworld", true, true)
)
);
assertEquals(
"Pack installed on disk.",
IrisModdedCommands.downloadCompletionMessage(
new PackDownloader.PackInstallResult("overworld", true, false)
)
);
assertNull(IrisModdedCommands.downloadCompletionMessage(
new PackDownloader.PackInstallResult("overworld", false, false)
));
assertNull(IrisModdedCommands.downloadCompletionMessage(null));
}
@Test
public void downloadBusyMessageDistinguishesPackDownloadsFromOtherLifecycleWork() {
LifecycleOperationCoordinator.ActiveOperation packDownload = new LifecycleOperationCoordinator.ActiveOperation(
1L,
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD,
"overworld"
);
LifecycleOperationCoordinator.ActiveOperation worldCreation = new LifecycleOperationCoordinator.ActiveOperation(
2L,
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
"iris_world"
);
assertEquals(
IrisLanguage.plain(PackDownloadMessages.IN_PROGRESS),
IrisModdedCommands.downloadBusyMessage(packDownload)
);
assertEquals(
"Iris pack changes are busy with world_create for 'iris_world'. Try again when it completes.",
IrisModdedCommands.downloadBusyMessage(worldCreation)
);
}
@Test @Test
public void helpDocumentsParityCommandsAndPlatformStubs() { public void helpDocumentsParityCommandsAndPlatformStubs() {
assertTrue(ModdedCommandHelp.documents("what", "here")); assertTrue(ModdedCommandHelp.documents("what", "here"));
@@ -28,6 +28,7 @@ import java.util.stream.Stream;
public final class WorldReplacementFilesystem { public final class WorldReplacementFilesystem {
private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace"; private static final String CODE_WORKSPACE_SUFFIX = ".code-workspace";
private static final String MACOS_FINDER_METADATA_FILE = ".DS_Store";
private static final List<Path> PAPER_WORLD_METADATA = List.of( private static final List<Path> PAPER_WORLD_METADATA = List.of(
Path.of("data/paper/metadata.dat"), Path.of("data/paper/metadata.dat"),
Path.of("data/paper/level_overrides.dat"), Path.of("data/paper/level_overrides.dat"),
@@ -315,9 +316,12 @@ public final class WorldReplacementFilesystem {
} }
private static boolean isGeneratedPackMetadata(Path relative, BasicFileAttributes attributes) { private static boolean isGeneratedPackMetadata(Path relative, BasicFileAttributes attributes) {
return PackDirectoryResolver.isHiddenName(relative.getName(0).toString()) String fileName = relative.getFileName().toString();
|| attributes.isRegularFile() if (PackDirectoryResolver.isHiddenName(relative.getName(0).toString())) {
&& relative.getFileName().toString().endsWith(CODE_WORKSPACE_SUFFIX); return true;
}
return attributes.isRegularFile()
&& (MACOS_FINDER_METADATA_FILE.equals(fileName) || fileName.endsWith(CODE_WORKSPACE_SUFFIX));
} }
private static BasicFileAttributes requireSafeEntry(Path path) throws IOException { private static BasicFileAttributes requireSafeEntry(Path path) throws IOException {
@@ -16,6 +16,7 @@ import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import java.util.Objects; import java.util.Objects;
import java.util.OptionalLong;
public final class WorldReplacementSeed { public final class WorldReplacementSeed {
private static final Path WORLD_GEN_SETTINGS = Path.of("data/minecraft/world_gen_settings.dat"); private static final Path WORLD_GEN_SETTINGS = Path.of("data/minecraft/world_gen_settings.dat");
@@ -32,6 +33,27 @@ public final class WorldReplacementSeed {
return requireData(namedTag, settings).getLongTag("seed").asLong(); return requireData(namedTag, settings).getLongTag("seed").asLong();
} }
public static long stageAuthoritativeSeed(
Path sourceWorldDirectory,
Path stagedWorldDirectory,
OptionalLong requestedSeed
) throws IOException {
Path sourceWorld = Objects.requireNonNull(sourceWorldDirectory, "sourceWorldDirectory")
.toAbsolutePath()
.normalize();
Path stagedWorld = Objects.requireNonNull(stagedWorldDirectory, "stagedWorldDirectory")
.toAbsolutePath()
.normalize();
OptionalLong requiredRequestedSeed = Objects.requireNonNull(requestedSeed, "requestedSeed");
Path source = sourceWorld.resolve(WORLD_GEN_SETTINGS);
NamedTag namedTag = readSettings(source);
CompoundTag data = requireData(namedTag, source);
long retainedSeed = data.getLongTag("seed").asLong();
long effectiveSeed = requiredRequestedSeed.orElse(retainedSeed);
writeSettings(stagedWorld, namedTag, data, effectiveSeed);
return effectiveSeed;
}
public static void copyWithAuthoritativeSeed( public static void copyWithAuthoritativeSeed(
Path sourceWorldDirectory, Path sourceWorldDirectory,
Path targetWorldDirectory, Path targetWorldDirectory,
@@ -44,11 +66,19 @@ public final class WorldReplacementSeed {
.toAbsolutePath() .toAbsolutePath()
.normalize(); .normalize();
Path source = sourceWorld.resolve(WORLD_GEN_SETTINGS); Path source = sourceWorld.resolve(WORLD_GEN_SETTINGS);
Path target = targetWorld.resolve(WORLD_GEN_SETTINGS);
NamedTag namedTag = readSettings(source); NamedTag namedTag = readSettings(source);
CompoundTag data = requireData(namedTag, source); CompoundTag data = requireData(namedTag, source);
data.putLong("seed", seed); writeSettings(targetWorld, namedTag, data, seed);
}
private static void writeSettings(
Path targetWorld,
NamedTag namedTag,
CompoundTag data,
long seed
) throws IOException {
Path target = targetWorld.resolve(WORLD_GEN_SETTINGS);
data.putLong("seed", seed);
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
throw new IOException("Staged Paper world generation settings already exist: " + target); throw new IOException("Staged Paper world generation settings already exist: " + target);
} }
@@ -68,6 +98,7 @@ public final class WorldReplacementSeed {
} catch (AtomicMoveNotSupportedException exception) { } catch (AtomicMoveNotSupportedException exception) {
Files.move(staged, target); Files.move(staged, target);
} }
forceSettingsHierarchy(targetWorld, parent);
} finally { } finally {
Files.deleteIfExists(staged); Files.deleteIfExists(staged);
} }
@@ -78,6 +109,18 @@ public final class WorldReplacementSeed {
} }
} }
private static void forceSettingsHierarchy(Path targetWorld, Path settingsParent) throws IOException {
Path directory = settingsParent;
while (directory != null && directory.startsWith(targetWorld)) {
DirectoryDurability.forceDirectoryRequired(directory);
if (directory.equals(targetWorld)) {
return;
}
directory = directory.getParent();
}
throw new IOException("Staged Paper world generation settings escaped their target directory.");
}
private static NamedTag readSettings(Path settings) throws IOException { private static NamedTag readSettings(Path settings) throws IOException {
BasicFileAttributes attributes = Files.readAttributes( BasicFileAttributes attributes = Files.readAttributes(
settings, settings,
@@ -1,5 +1,6 @@
package art.arcane.iris.core.localization; package art.arcane.iris.core.localization;
import art.arcane.iris.util.common.format.C;
import art.arcane.volmlib.util.localization.LinesKey; import art.arcane.volmlib.util.localization.LinesKey;
import art.arcane.volmlib.util.localization.MessageKey; import art.arcane.volmlib.util.localization.MessageKey;
import art.arcane.volmlib.util.localization.PluralKey; import art.arcane.volmlib.util.localization.PluralKey;
@@ -9,6 +10,96 @@ import java.util.List;
import java.util.Map; import java.util.Map;
public final class PackDownloadMessages { public final class PackDownloadMessages {
public static final TextKey PROGRESS_START = TextKey.of(
"iris.runtime.pack_download.progress.start",
C.IRIS + "Iris " + C.GOLD + "PACK DOWNLOAD" + C.DARK_GRAY + " | " + C.WHITE + "{source}"
);
public static final TextKey PROGRESS_PHASE = TextKey.of(
"iris.runtime.pack_download.progress.phase",
C.IRIS + "Iris " + C.AQUA + "{phase}" + C.DARK_GRAY + " | " + C.GRAY + "{source}"
);
public static final TextKey PROGRESS_DETERMINATE = TextKey.of(
"iris.runtime.pack_download.progress.determinate",
"{bar}" + C.GRAY + " " + C.YELLOW + "{percent}%" + C.DARK_GRAY + " | "
+ C.WHITE + "{transferred}" + C.GRAY + "/" + C.WHITE + "{total}"
+ C.DARK_GRAY + " | " + C.AQUA + "{rate}/s"
);
public static final TextKey PROGRESS_INDETERMINATE = TextKey.of(
"iris.runtime.pack_download.progress.indeterminate",
"{bar}" + C.GRAY + " " + C.AQUA + "{phase}" + C.DARK_GRAY + " | "
+ C.WHITE + "{transferred}" + C.DARK_GRAY + " | " + C.AQUA + "{rate}/s"
);
public static final TextKey PROGRESS_DETAIL = TextKey.of(
"iris.runtime.pack_download.progress.detail",
C.DARK_GRAY + " - " + C.GRAY + "{detail}"
);
public static final TextKey PROGRESS_COMPLETE = TextKey.of(
"iris.runtime.pack_download.progress.complete",
C.GREEN + "Iris pack '{pack}' installed" + C.DARK_GRAY + " | "
+ C.WHITE + "{transferred}" + C.GRAY + " in " + C.WHITE + "{elapsed}"
);
public static final TextKey PROGRESS_UNCHANGED = TextKey.of(
"iris.runtime.pack_download.progress.unchanged",
C.YELLOW + "Iris pack '{pack}' is already installed."
);
public static final TextKey PROGRESS_FAILED = TextKey.of(
"iris.runtime.pack_download.progress.failed",
C.RED + "Iris pack download failed." + C.GRAY + " Review the download details above and retry."
);
public static final TextKey PROGRESS_FAILED_DETAIL = TextKey.of(
"iris.runtime.pack_download.progress.failed_detail",
C.RED + "Iris pack download failed." + C.GRAY + " {error}"
);
public static final TextKey PROGRESS_CANCELLED = TextKey.of(
"iris.runtime.pack_download.progress.cancelled",
C.YELLOW + "Iris pack download cancelled before publication."
);
public static final TextKey PROGRESS_RESTART = TextKey.of(
"iris.runtime.pack_download.progress.restart",
C.GOLD + "Restart required" + C.DARK_GRAY + " | "
+ C.GRAY + "Restart the server before creating or replacing a world with this pack."
);
public static final TextKey PROGRESS_PHASE_CONNECTING = TextKey.of(
"iris.runtime.pack_download.progress.phase.connecting",
"Connecting"
);
public static final TextKey PROGRESS_PHASE_DOWNLOADING = TextKey.of(
"iris.runtime.pack_download.progress.phase.downloading",
"Downloading"
);
public static final TextKey PROGRESS_PHASE_UNPACKING = TextKey.of(
"iris.runtime.pack_download.progress.phase.unpacking",
"Unpacking"
);
public static final TextKey PROGRESS_PHASE_VALIDATING = TextKey.of(
"iris.runtime.pack_download.progress.phase.validating",
"Validating"
);
public static final TextKey PROGRESS_PHASE_PUBLISHING = TextKey.of(
"iris.runtime.pack_download.progress.phase.publishing",
"Publishing"
);
public static final TextKey PROGRESS_SOURCE_REMOTE = TextKey.of(
"iris.runtime.pack_download.progress.source.remote",
"Remote ZIP"
);
public static final TextKey INVALID_SOURCE = TextKey.of(
"iris.runtime.pack_download.invalid_source",
C.RED + "Choose exactly one source: /iris download pack=overworld, "
+ "/iris download pack=underworld, or /iris download link=zip-url."
);
public static final TextKey INVALID_URL = TextKey.of(
"iris.runtime.pack_download.invalid_url",
C.RED + "Iris requires a valid HTTP or HTTPS .zip URL."
);
public static final TextKey INVALID_BUILT_IN = TextKey.of(
"iris.runtime.pack_download.invalid_built_in",
C.RED + "Iris only provides built-in downloads for 'overworld' and 'underworld'."
);
public static final TextKey SHUTTING_DOWN = TextKey.of(
"iris.runtime.pack_download.shutting_down",
C.YELLOW + "Iris is shutting down and is not accepting pack downloads."
);
public static final TextKey DOWNLOADING = TextKey.of( public static final TextKey DOWNLOADING = TextKey.of(
"iris.runtime.pack_download.downloading", "iris.runtime.pack_download.downloading",
"Downloading {url}" "Downloading {url}"
@@ -74,6 +165,10 @@ public final class PackDownloadMessages {
"iris.runtime.pack_download.already_installed", "iris.runtime.pack_download.already_installed",
"Pack {key} is already installed, skipping download." "Pack {key} is already installed, skipping download."
); );
public static final TextKey IN_PROGRESS = TextKey.of(
"iris.runtime.pack_download.in_progress",
"Another Iris pack download is already in progress. Wait for it to finish before retrying."
);
public static final TextKey VALIDATION_FAILED = TextKey.of( public static final TextKey VALIDATION_FAILED = TextKey.of(
"iris.runtime.pack_download.validation_failed", "iris.runtime.pack_download.validation_failed",
"Pack '{pack}' failed validation; world and Studio creation will be refused. Reasons:" "Pack '{pack}' failed validation; world and Studio creation will be refused. Reasons:"
@@ -96,6 +191,27 @@ public final class PackDownloadMessages {
); );
private static final List<MessageKey> KEYS = List.of( private static final List<MessageKey> KEYS = List.of(
PROGRESS_START,
PROGRESS_PHASE,
PROGRESS_DETERMINATE,
PROGRESS_INDETERMINATE,
PROGRESS_DETAIL,
PROGRESS_COMPLETE,
PROGRESS_UNCHANGED,
PROGRESS_FAILED,
PROGRESS_FAILED_DETAIL,
PROGRESS_CANCELLED,
PROGRESS_RESTART,
PROGRESS_PHASE_CONNECTING,
PROGRESS_PHASE_DOWNLOADING,
PROGRESS_PHASE_UNPACKING,
PROGRESS_PHASE_VALIDATING,
PROGRESS_PHASE_PUBLISHING,
PROGRESS_SOURCE_REMOTE,
INVALID_SOURCE,
INVALID_URL,
INVALID_BUILT_IN,
SHUTTING_DOWN,
DOWNLOADING, DOWNLOADING,
FAILED_TO_FIND, FAILED_TO_FIND,
UNPACKING, UNPACKING,
@@ -111,6 +227,7 @@ public final class PackDownloadMessages {
PACK_KEY_CONFLICT, PACK_KEY_CONFLICT,
ACQUIRED, ACQUIRED,
ALREADY_INSTALLED, ALREADY_INSTALLED,
IN_PROGRESS,
VALIDATION_FAILED, VALIDATION_FAILED,
VALIDATION_REASON, VALIDATION_REASON,
VALIDATED_WITH_WARNINGS, VALIDATED_WITH_WARNINGS,
@@ -0,0 +1,144 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.core.pack;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.spi.IrisLogging;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
public final class PackDownloadExecution implements Runnable {
private final Object monitor = new Object();
private final LifecycleOperationCoordinator.Lease lease;
private final Work work;
private final PackDownloader.DownloadCancellation cancellation;
private final CompletableFuture<Void> completion;
private final AtomicBoolean finished;
private Future<?> future;
private boolean cancellationRequested;
private boolean started;
public PackDownloadExecution(LifecycleOperationCoordinator.Lease lease, Work work) {
this.lease = Objects.requireNonNull(lease, "lease");
this.work = Objects.requireNonNull(work, "work");
cancellation = new PackDownloader.DownloadCancellation();
completion = new CompletableFuture<>();
finished = new AtomicBoolean();
}
public void bind(Future<?> submittedFuture) {
Future<?> acceptedFuture = Objects.requireNonNull(submittedFuture, "submittedFuture");
boolean cancelBeforeStart;
synchronized (monitor) {
future = acceptedFuture;
cancelBeforeStart = cancellationRequested && !started;
}
if (cancelBeforeStart) {
acceptedFuture.cancel(false);
finish();
}
}
public void onCompletion(Runnable callback) {
Runnable completionCallback = Objects.requireNonNull(callback, "callback");
completion.whenComplete((ignored, failure) -> completionCallback.run());
}
public void cancel() {
Future<?> submittedFuture;
boolean cancelBeforeStart;
synchronized (monitor) {
cancellationRequested = true;
submittedFuture = future;
cancelBeforeStart = !started;
}
cancellation.cancel();
if (cancelBeforeStart) {
if (submittedFuture != null) {
submittedFuture.cancel(false);
}
finish();
}
}
public boolean await(long timeout, TimeUnit unit) throws InterruptedException {
try {
completion.get(timeout, unit);
return true;
} catch (TimeoutException exception) {
return false;
} catch (ExecutionException exception) {
return true;
}
}
public boolean isPublishing() {
return cancellation.isPublishing();
}
public boolean isComplete() {
return completion.isDone();
}
@Override
public void run() {
synchronized (monitor) {
if (cancellationRequested) {
finish();
return;
}
started = true;
}
try {
cancellation.attachCurrentThread();
work.run(cancellation);
} catch (PackDownloader.PackDownloadCancelledException ignored) {
} catch (Throwable failure) {
IrisLogging.reportError("Pack download worker failed.", failure);
} finally {
cancellation.complete();
finish();
}
}
private void finish() {
if (!finished.compareAndSet(false, true)) {
return;
}
try {
lease.close();
} catch (Throwable failure) {
IrisLogging.reportError("Failed to release the pack download lifecycle lease.", failure);
} finally {
completion.complete(null);
}
}
@FunctionalInterface
public interface Work {
void run(PackDownloader.DownloadCancellation cancellation) throws Exception;
}
}
@@ -25,18 +25,23 @@ import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.util.common.misc.WebCache; import art.arcane.iris.util.common.misc.WebCache;
import art.arcane.volmlib.util.io.IO;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
import org.zeroturnaround.zip.commons.FileUtils; import org.zeroturnaround.zip.commons.FileUtils;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InterruptedIOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.URI; import java.net.URI;
import java.nio.file.FileVisitResult;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.LinkOption; import java.nio.file.LinkOption;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
@@ -48,6 +53,7 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -74,6 +80,9 @@ public final class PackDownloader {
256L * 1024L * 1024L 256L * 1024L * 1024L
); );
private static final ConcurrentHashMap<String, DownloadLock> DOWNLOAD_LOCKS = new ConcurrentHashMap<>(); private static final ConcurrentHashMap<String, DownloadLock> DOWNLOAD_LOCKS = new ConcurrentHashMap<>();
private static final AtomicBoolean DOWNLOAD_ACTIVE = new AtomicBoolean();
private static final DownloadProgressListener NO_DOWNLOAD_PROGRESS = progress -> {
};
private PackDownloader() { private PackDownloader() {
} }
@@ -174,8 +183,52 @@ public final class PackDownloader {
return downloadBuiltIn(packsFolder, DEFAULT_OVERWORLD_PACK, forceOverwrite, feedback); return downloadBuiltIn(packsFolder, DEFAULT_OVERWORLD_PACK, forceOverwrite, feedback);
} }
public static PackInstallResult downloadDefaultOverworld(File packsFolder, boolean forceOverwrite,
Consumer<String> feedback,
DownloadProgressListener progressListener) throws IOException {
return downloadBuiltIn(packsFolder, DEFAULT_OVERWORLD_PACK, forceOverwrite, feedback, progressListener);
}
public static PackInstallResult downloadBuiltIn(File packsFolder, String pack, boolean forceOverwrite, public static PackInstallResult downloadBuiltIn(File packsFolder, String pack, boolean forceOverwrite,
Consumer<String> feedback) throws IOException { Consumer<String> feedback) throws IOException {
return downloadBuiltIn(
packsFolder,
pack,
forceOverwrite,
feedback,
new DownloadCancellation(),
NO_DOWNLOAD_PROGRESS
);
}
public static PackInstallResult downloadBuiltIn(File packsFolder, String pack, boolean forceOverwrite,
Consumer<String> feedback,
DownloadProgressListener progressListener) throws IOException {
return downloadBuiltIn(
packsFolder,
pack,
forceOverwrite,
feedback,
new DownloadCancellation(),
progressListener
);
}
public static PackInstallResult downloadBuiltIn(File packsFolder, String pack, boolean forceOverwrite,
Consumer<String> feedback, DownloadCancellation cancellation) throws IOException {
return downloadBuiltIn(
packsFolder,
pack,
forceOverwrite,
feedback,
cancellation,
NO_DOWNLOAD_PROGRESS
);
}
public static PackInstallResult downloadBuiltIn(File packsFolder, String pack, boolean forceOverwrite,
Consumer<String> feedback, DownloadCancellation cancellation,
DownloadProgressListener progressListener) throws IOException {
String url = pack == null ? null : BUILT_IN_PACK_URLS.get(pack); String url = pack == null ? null : BUILT_IN_PACK_URLS.get(pack);
if (url == null) { if (url == null) {
throw new IllegalArgumentException("Pack '" + pack + "' is not a built-in Iris download"); throw new IllegalArgumentException("Pack '" + pack + "' is not a built-in Iris download");
@@ -185,12 +238,52 @@ public final class PackDownloader {
url, url,
forceOverwrite, forceOverwrite,
pack, pack,
feedback feedback,
cancellation,
progressListener
); );
} }
public static PackInstallResult downloadUrl(File packsFolder, String url, boolean forceOverwrite, public static PackInstallResult downloadUrl(File packsFolder, String url, boolean forceOverwrite,
Consumer<String> feedback) throws IOException { Consumer<String> feedback) throws IOException {
return downloadUrl(
packsFolder,
url,
forceOverwrite,
feedback,
new DownloadCancellation(),
NO_DOWNLOAD_PROGRESS
);
}
public static PackInstallResult downloadUrl(File packsFolder, String url, boolean forceOverwrite,
Consumer<String> feedback,
DownloadProgressListener progressListener) throws IOException {
return downloadUrl(
packsFolder,
url,
forceOverwrite,
feedback,
new DownloadCancellation(),
progressListener
);
}
public static PackInstallResult downloadUrl(File packsFolder, String url, boolean forceOverwrite,
Consumer<String> feedback, DownloadCancellation cancellation) throws IOException {
return downloadUrl(
packsFolder,
url,
forceOverwrite,
feedback,
cancellation,
NO_DOWNLOAD_PROGRESS
);
}
public static PackInstallResult downloadUrl(File packsFolder, String url, boolean forceOverwrite,
Consumer<String> feedback, DownloadCancellation cancellation,
DownloadProgressListener progressListener) throws IOException {
if (!isDirectZipUrl(url)) { if (!isDirectZipUrl(url)) {
throw new IllegalArgumentException("Pack URL must be an HTTP or HTTPS .zip link"); throw new IllegalArgumentException("Pack URL must be an HTTP or HTTPS .zip link");
} }
@@ -199,51 +292,106 @@ public final class PackDownloader {
url.trim(), url.trim(),
forceOverwrite, forceOverwrite,
null, null,
feedback feedback,
cancellation,
progressListener
); );
} }
private static PackInstallResult downloadArchive(File packsFolder, String url, boolean forceOverwrite, private static PackInstallResult downloadArchive(File packsFolder, String url, boolean forceOverwrite,
String expectedKey, Consumer<String> feedback) throws IOException { String expectedKey, Consumer<String> feedback,
DownloadCancellation cancellation,
DownloadProgressListener progressListener) throws IOException {
Objects.requireNonNull(packsFolder, "packsFolder"); Objects.requireNonNull(packsFolder, "packsFolder");
DownloadCancellation control = Objects.requireNonNull(cancellation, "cancellation");
Consumer<String> output = feedback == null ? ignored -> { Consumer<String> output = feedback == null ? ignored -> {
} : feedback; } : feedback;
DownloadProgressListener progress = progressListener == null ? NO_DOWNLOAD_PROGRESS : progressListener;
if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) { if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) {
throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'"); throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'");
} }
String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "url:" + url; if (!DOWNLOAD_ACTIVE.compareAndSet(false, true)) {
return withDownloadLock(lockKey, () -> { throw new PackDownloadBusyException();
boolean present = isBuiltInPack(expectedKey) }
? isBuiltInPackPresent(packsFolder, expectedKey) try {
: isPackPresent(packsFolder, expectedKey); control.attachCurrentThread();
if (!forceOverwrite && present) { control.checkpoint();
sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey))); String lockKey = expectedKey != null && !expectedKey.isBlank()
return new PackInstallResult(expectedKey, false, false); ? "key:" + expectedKey
} : "url:" + IO.hash(url);
return downloadLocked(packsFolder, url, forceOverwrite, expectedKey, lockKey, output); return withDownloadLock(lockKey, () -> {
}); control.checkpoint();
boolean present = isBuiltInPack(expectedKey)
? isBuiltInPackPresent(packsFolder, expectedKey)
: isPackPresent(packsFolder, expectedKey);
if (!forceOverwrite && present) {
sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
return new PackInstallResult(expectedKey, false, false);
}
return downloadLocked(
packsFolder,
url,
forceOverwrite,
expectedKey,
lockKey,
output,
control,
progress
);
});
} finally {
control.complete();
DOWNLOAD_ACTIVE.set(false);
}
} }
private static PackInstallResult downloadLocked(File packsFolder, String url, boolean forceOverwrite, private static PackInstallResult downloadLocked(File packsFolder, String url, boolean forceOverwrite,
String expectedKey, String heldLockKey, Consumer<String> feedback) throws IOException { String expectedKey, String heldLockKey, Consumer<String> feedback,
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " "); DownloadCancellation cancellation,
File zip = WebCache.getNonCachedFile("pack-archive", url, ARCHIVE_LIMITS.maxArchiveBytes()); DownloadProgressListener progressListener) throws IOException {
cancellation.checkpoint();
String source = expectedKey == null || expectedKey.isBlank()
? IrisLanguage.plain(PackDownloadMessages.PROGRESS_SOURCE_REMOTE)
: expectedKey;
sendProgress(progressListener, DownloadProgress.phase(DownloadPhase.CONNECTING));
sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.DOWNLOADING,
MessageArgument.untrusted("url", source)
) + " ");
File zip = WebCache.getNonCachedFile(
"pack-archive",
url,
ARCHIVE_LIMITS.maxArchiveBytes(),
transfer -> sendProgress(progressListener, DownloadProgress.transfer(transfer))
);
cancellation.checkpoint();
File temp = WebCache.getTemp(); File temp = WebCache.getTemp();
File work = new File(temp, "dl-" + UUID.randomUUID()); File work = new File(temp, "dl-" + UUID.randomUUID());
try { try {
if (zip == null || !zip.exists()) { if (zip == null || !zip.exists()) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.FAILED_TO_FIND, MessageArgument.untrusted("url", url))); sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.FAILED_TO_FIND,
MessageArgument.untrusted("url", source)
));
return null; return null;
} }
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACKING, MessageArgument.untrusted("repository", url))); sendProgress(progressListener, DownloadProgress.phase(DownloadPhase.UNPACKING));
sendFeedback(feedback, IrisLanguage.plain(
PackDownloadMessages.UNPACKING,
MessageArgument.untrusted("repository", source)
));
try { try {
unpackArchive(zip.toPath(), work.toPath(), ARCHIVE_LIMITS); unpackArchive(zip.toPath(), work.toPath(), ARCHIVE_LIMITS, cancellation);
} catch (IOException exception) { } catch (IOException exception) {
if (exception instanceof PackDownloadCancelledException) {
throw exception;
}
IrisLogging.reportError(exception); IrisLogging.reportError(exception);
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACK_FAILED)); sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACK_FAILED));
return null; return null;
} }
cancellation.checkpoint();
File[] zipFiles = work.listFiles(); File[] zipFiles = work.listFiles();
if (zipFiles == null) { if (zipFiles == null) {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.NO_EXTRACTED_FILES)); sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.NO_EXTRACTED_FILES));
@@ -254,7 +402,16 @@ public final class PackDownloader {
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_ARCHIVE_FORMAT)); sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_ARCHIVE_FORMAT));
return null; return null;
} }
return installExtractedPack(packsFolder, directory, forceOverwrite, expectedKey, heldLockKey, feedback); return installExtractedPack(
packsFolder,
directory,
forceOverwrite,
expectedKey,
heldLockKey,
feedback,
cancellation,
progressListener
);
} finally { } finally {
deleteDirectory(work); deleteDirectory(work);
} }
@@ -286,11 +443,22 @@ public final class PackDownloader {
Objects.requireNonNull(extractedPack, "extractedPack"); Objects.requireNonNull(extractedPack, "extractedPack");
Consumer<String> output = feedback == null ? ignored -> { Consumer<String> output = feedback == null ? ignored -> {
} : feedback; } : feedback;
return installExtractedPack(packsFolder, extractedPack, forceOverwrite, expectedKey, null, output); return installExtractedPack(
packsFolder,
extractedPack,
forceOverwrite,
expectedKey,
null,
output,
null,
NO_DOWNLOAD_PROGRESS
);
} }
private static PackInstallResult installExtractedPack(File packsFolder, File extractedPack, boolean forceOverwrite, private static PackInstallResult installExtractedPack(File packsFolder, File extractedPack, boolean forceOverwrite,
String expectedKey, String heldLockKey, Consumer<String> feedback) throws IOException { String expectedKey, String heldLockKey, Consumer<String> feedback,
DownloadCancellation cancellation,
DownloadProgressListener progressListener) throws IOException {
if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) { if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) {
throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'"); throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'");
} }
@@ -298,17 +466,43 @@ public final class PackDownloader {
Files.createDirectories(packsRoot); Files.createDirectories(packsRoot);
Path staging = packsRoot.resolve(".iris-import-" + UUID.randomUUID()); Path staging = packsRoot.resolve(".iris-import-" + UUID.randomUUID());
try { try {
FileUtils.copyDirectory(extractedPack, staging.toFile()); checkpoint(cancellation);
if (cancellation == null) {
FileUtils.copyDirectory(extractedPack, staging.toFile());
} else {
copyDirectory(extractedPack.toPath(), staging, cancellation);
}
checkpoint(cancellation);
sendProgress(progressListener, DownloadProgress.phase(DownloadPhase.VALIDATING));
PreparedPack prepared = prepareStagedPack(staging.toFile(), expectedKey, feedback); PreparedPack prepared = prepareStagedPack(staging.toFile(), expectedKey, feedback);
if (prepared == null) { if (prepared == null) {
return null; return null;
} }
checkpoint(cancellation);
String destinationLockKey = "key:" + prepared.key(); String destinationLockKey = "key:" + prepared.key();
if (destinationLockKey.equals(heldLockKey)) { if (destinationLockKey.equals(heldLockKey)) {
return publishPreparedPack(packsFolder, packsRoot, staging, prepared, forceOverwrite, feedback); return publishPreparedPack(
packsFolder,
packsRoot,
staging,
prepared,
forceOverwrite,
feedback,
cancellation,
progressListener
);
} }
return withDownloadLock(destinationLockKey, return withDownloadLock(destinationLockKey,
() -> publishPreparedPack(packsFolder, packsRoot, staging, prepared, forceOverwrite, feedback)); () -> publishPreparedPack(
packsFolder,
packsRoot,
staging,
prepared,
forceOverwrite,
feedback,
cancellation,
progressListener
));
} finally { } finally {
deleteDirectory(staging.toFile()); deleteDirectory(staging.toFile());
} }
@@ -396,7 +590,10 @@ public final class PackDownloader {
} }
private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared, private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared,
boolean forceOverwrite, Consumer<String> feedback) throws IOException { boolean forceOverwrite, Consumer<String> feedback,
DownloadCancellation cancellation,
DownloadProgressListener progressListener) throws IOException {
sendProgress(progressListener, DownloadProgress.phase(DownloadPhase.PUBLISHING));
Path target = packsRoot.resolve(prepared.key()).normalize(); Path target = packsRoot.resolve(prepared.key()).normalize();
if (!Objects.equals(target.getParent(), packsRoot)) { if (!Objects.equals(target.getParent(), packsRoot)) {
throw new IOException("Pack target escapes the packs folder: " + target); throw new IOException("Pack target escapes the packs folder: " + target);
@@ -442,6 +639,9 @@ public final class PackDownloader {
); );
return null; return null;
} }
if (cancellation != null) {
cancellation.beginPublication();
}
IrisData.getLoaded(new File(packsFolder, prepared.key())).ifPresent(IrisData::close); IrisData.getLoaded(new File(packsFolder, prepared.key())).ifPresent(IrisData::close);
IrisData.getLoaded(target.toFile()).ifPresent(IrisData::close); IrisData.getLoaded(target.toFile()).ifPresent(IrisData::close);
try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) { try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) {
@@ -461,6 +661,7 @@ public final class PackDownloader {
PackDownloadMessages.ACQUIRED, PackDownloadMessages.ACQUIRED,
MessageArgument.untrusted("name", prepared.name()) MessageArgument.untrusted("name", prepared.name())
)); ));
sendProgress(progressListener, DownloadProgress.terminal());
return new PackInstallResult(prepared.key(), true, true); return new PackInstallResult(prepared.key(), true, true);
} }
@@ -525,9 +726,15 @@ public final class PackDownloader {
} }
static void unpackArchive(Path archive, Path destination, ArchiveLimits limits) throws IOException { static void unpackArchive(Path archive, Path destination, ArchiveLimits limits) throws IOException {
unpackArchive(archive, destination, limits, null);
}
static void unpackArchive(Path archive, Path destination, ArchiveLimits limits,
DownloadCancellation cancellation) throws IOException {
Path source = Objects.requireNonNull(archive, "archive").toAbsolutePath().normalize(); Path source = Objects.requireNonNull(archive, "archive").toAbsolutePath().normalize();
Path root = Objects.requireNonNull(destination, "destination").toAbsolutePath().normalize(); Path root = Objects.requireNonNull(destination, "destination").toAbsolutePath().normalize();
ArchiveLimits safety = Objects.requireNonNull(limits, "limits"); ArchiveLimits safety = Objects.requireNonNull(limits, "limits");
checkpoint(cancellation);
if (Files.isSymbolicLink(source) || !Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) { if (Files.isSymbolicLink(source) || !Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Pack archive is missing or unsafe: " + source); throw new IOException("Pack archive is missing or unsafe: " + source);
} }
@@ -548,6 +755,7 @@ public final class PackDownloader {
try (InputStream input = Files.newInputStream(source); ZipInputStream zip = new ZipInputStream(input)) { try (InputStream input = Files.newInputStream(source); ZipInputStream zip = new ZipInputStream(input)) {
ZipEntry entry; ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) { while ((entry = zip.getNextEntry()) != null) {
checkpoint(cancellation);
entryCount++; entryCount++;
if (entryCount > safety.maxEntries()) { if (entryCount > safety.maxEntries()) {
throw new IOException("Pack archive contains too many entries."); throw new IOException("Pack archive contains too many entries.");
@@ -576,6 +784,7 @@ public final class PackDownloader {
byte[] buffer = new byte[8192]; byte[] buffer = new byte[8192];
int read; int read;
while ((read = zip.read(buffer)) != -1) { while ((read = zip.read(buffer)) != -1) {
checkpoint(cancellation);
if (read == 0) { if (read == 0) {
continue; continue;
} }
@@ -598,6 +807,52 @@ public final class PackDownloader {
} }
} }
private static void copyDirectory(Path source, Path destination,
DownloadCancellation cancellation) throws IOException {
Path sourceRoot = source.toAbsolutePath().normalize();
Path destinationRoot = destination.toAbsolutePath().normalize();
Files.walkFileTree(sourceRoot, new SimpleFileVisitor<Path>() {
private final byte[] buffer = new byte[8192];
@Override
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) throws IOException {
cancellation.checkpoint();
Files.createDirectories(destinationRoot.resolve(sourceRoot.relativize(directory)));
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
cancellation.checkpoint();
if (Files.isSymbolicLink(file)) {
throw new IOException("Downloaded pack contains an unsafe symbolic link: " + file);
}
Path target = destinationRoot.resolve(sourceRoot.relativize(file));
try (InputStream input = Files.newInputStream(file);
OutputStream output = Files.newOutputStream(
target,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE
)) {
int read;
while ((read = input.read(buffer)) != -1) {
cancellation.checkpoint();
if (read > 0) {
output.write(buffer, 0, read);
}
}
}
return FileVisitResult.CONTINUE;
}
});
}
private static void checkpoint(DownloadCancellation cancellation) throws PackDownloadCancelledException {
if (cancellation != null) {
cancellation.checkpoint();
}
}
private static String normalizeArchiveEntry(String rawName) throws IOException { private static String normalizeArchiveEntry(String rawName) throws IOException {
if (rawName == null || rawName.isBlank() || rawName.indexOf('\0') >= 0 if (rawName == null || rawName.isBlank() || rawName.indexOf('\0') >= 0
|| rawName.startsWith("/") || rawName.startsWith("\\")) { || rawName.startsWith("/") || rawName.startsWith("\\")) {
@@ -631,6 +886,14 @@ public final class PackDownloader {
} }
} }
private static void sendProgress(DownloadProgressListener listener, DownloadProgress progress) {
try {
listener.onProgress(progress);
} catch (RuntimeException exception) {
IrisLogging.reportError("Pack download progress delivery failed", exception);
}
}
private static void sendValidationFeedback(PackValidationResult result, Consumer<String> feedback) { private static void sendValidationFeedback(PackValidationResult result, Consumer<String> feedback) {
if (!result.isLoadable()) { if (!result.isLoadable()) {
sendFeedback(feedback, IrisLanguage.plain( sendFeedback(feedback, IrisLanguage.plain(
@@ -669,6 +932,118 @@ public final class PackDownloader {
public record PackInstallResult(String key, boolean changed, boolean restartRequired) { public record PackInstallResult(String key, boolean changed, boolean restartRequired) {
} }
public record DownloadProgress(DownloadPhase phase, long transferredBytes, long totalBytes,
long elapsedMillis, boolean complete) {
private static DownloadProgress phase(DownloadPhase phase) {
return new DownloadProgress(phase, 0L, -1L, 0L, false);
}
private static DownloadProgress transfer(WebCache.TransferProgress transfer) {
return new DownloadProgress(
DownloadPhase.DOWNLOADING,
transfer.transferredBytes(),
transfer.contentLength(),
transfer.elapsedMillis(),
false
);
}
private static DownloadProgress terminal() {
return new DownloadProgress(DownloadPhase.PUBLISHING, 0L, -1L, 0L, true);
}
}
public enum DownloadPhase {
CONNECTING,
DOWNLOADING,
UNPACKING,
VALIDATING,
PUBLISHING
}
@FunctionalInterface
public interface DownloadProgressListener {
void onProgress(DownloadProgress progress);
}
public static final class PackDownloadBusyException extends IOException {
public PackDownloadBusyException() {
super(IrisLanguage.plain(PackDownloadMessages.IN_PROGRESS));
}
}
public static final class PackDownloadCancelledException extends InterruptedIOException {
private PackDownloadCancelledException() {
super("Pack download cancelled.");
}
}
public static final class DownloadCancellation {
private final Object monitor = new Object();
private boolean cancelled;
private boolean publishing;
private Thread worker;
public void cancel() {
Thread interruptTarget;
synchronized (monitor) {
cancelled = true;
interruptTarget = publishing ? null : worker;
}
if (interruptTarget != null) {
interruptTarget.interrupt();
}
}
public boolean isPublishing() {
synchronized (monitor) {
return publishing;
}
}
void attachCurrentThread() throws PackDownloadCancelledException {
synchronized (monitor) {
Thread current = Thread.currentThread();
if (worker != null && worker != current) {
throw new IllegalStateException("Pack download cancellation is already attached to another thread.");
}
worker = current;
checkCancelled(current);
}
}
void checkpoint() throws PackDownloadCancelledException {
synchronized (monitor) {
checkCancelled(Thread.currentThread());
}
}
void beginPublication() throws PackDownloadCancelledException {
synchronized (monitor) {
checkCancelled(Thread.currentThread());
publishing = true;
}
}
void complete() {
boolean clearInterrupt;
synchronized (monitor) {
clearInterrupt = cancelled && worker == Thread.currentThread();
publishing = false;
worker = null;
}
if (clearInterrupt) {
Thread.interrupted();
}
}
private void checkCancelled(Thread current) throws PackDownloadCancelledException {
if (cancelled || current.isInterrupted()) {
throw new PackDownloadCancelledException();
}
}
}
record ArchiveLimits(long maxArchiveBytes, int maxEntries, long maxExpandedBytes, long maxEntryBytes) { record ArchiveLimits(long maxArchiveBytes, int maxEntries, long maxExpandedBytes, long maxEntryBytes) {
ArchiveLimits { ArchiveLimits {
if (maxArchiveBytes < 1L || maxEntries < 1 || maxExpandedBytes < 1L || maxEntryBytes < 1L) { if (maxArchiveBytes < 1L || maxEntries < 1 || maxExpandedBytes < 1L || maxEntryBytes < 1L) {
@@ -0,0 +1,559 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.PackDownloadMessages;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.format.Form;
import art.arcane.volmlib.util.hud.HudPriority;
import art.arcane.volmlib.util.hud.HudSlotClaim;
import art.arcane.volmlib.util.hud.HudSlotRequest;
import art.arcane.volmlib.util.hud.HudSurface;
import art.arcane.volmlib.util.localization.MessageArgument;
import org.bukkit.boss.BarColor;
import org.bukkit.boss.BarStyle;
import org.bukkit.entity.Player;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Pattern;
final class PackDownloadProgressReporter implements PackDownloader.DownloadProgressListener {
static final int PROGRESS_BAR_WIDTH = 24;
private static final int INDETERMINATE_SEGMENT_WIDTH = 5;
private static final int HUD_PULSE_TICKS = 5;
private static final int HUD_TERMINAL_TICKS = 60;
private static final long HUD_CLAIM_TTL_MILLIS = HUD_TERMINAL_TICKS * 50L + 1_000L;
private static final long ACTION_INTERVAL_MILLIS = 250L;
private static final long CHAT_INTERVAL_MILLIS = 5_000L;
private static final int CHAT_PERCENT_STEP = 10;
private static final int MAX_DETAIL_CHARACTERS = 320;
private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)\\u00a7[0-9A-FK-ORX]");
private static final AtomicLong SESSION_IDS = new AtomicLong();
private final VolmitSender sender;
private final String source;
private final String sensitiveSource;
private final String escapedSensitiveSource;
private final Player player;
private final UUID playerId;
private final String hudLaneId;
private PackDownloader.DownloadPhase phase;
private PackDownloader.DownloadProgress latestProgress;
private HudSlotClaim hudClaim;
private long transferredBytes;
private long transferElapsedMillis;
private long phaseStartedMillis;
private long lastActionMillis;
private long lastChatMillis;
private int lastChatPercent;
private int pulseTaskId;
private boolean started;
private boolean finished;
private boolean listenerDisabled;
private boolean hudDisabled;
PackDownloadProgressReporter(VolmitSender sender, String source) {
this(sender, source, null);
}
PackDownloadProgressReporter(VolmitSender sender, String source, String sensitiveSource) {
this.sender = Objects.requireNonNull(sender, "sender");
this.source = normalizeUntrusted(source);
this.sensitiveSource = normalizeSensitive(sensitiveSource);
escapedSensitiveSource = this.sensitiveSource == null
? null
: escapeLocalizationUntrusted(this.sensitiveSource);
player = sender.isPlayer() ? sender.player() : null;
playerId = player == null ? null : player.getUniqueId();
hudLaneId = "iris:pack-download-" + Long.toUnsignedString(SESSION_IDS.incrementAndGet());
lastActionMillis = Long.MIN_VALUE;
lastChatMillis = Long.MIN_VALUE;
lastChatPercent = -CHAT_PERCENT_STEP;
pulseTaskId = -1;
}
synchronized void start() {
if (started || finished) {
return;
}
started = true;
phaseStartedMillis = System.currentTimeMillis();
deliverChat(IrisLanguage.text(
PackDownloadMessages.PROGRESS_START,
MessageArgument.untrusted("source", source)
));
if (player == null || !BukkitPlatform.hasHud()) {
return;
}
hudClaim = BukkitPlatform.hudSlots().open(player, new HudSlotRequest(
hudLaneId,
HudPriority.PROGRESS,
HUD_CLAIM_TTL_MILLIS,
List.of(HudSurface.ACTION_BAR, HudSurface.BOSS_BAR)
));
int scheduledTaskId = J.ar(this::pulseHud, HUD_PULSE_TICKS);
pulseTaskId = scheduledTaskId;
if (finished || hudDisabled) {
J.car(scheduledTaskId);
pulseTaskId = -1;
}
}
@Override
public synchronized void onProgress(PackDownloader.DownloadProgress progress) {
if (finished || listenerDisabled || progress == null) {
return;
}
try {
if (!started) {
start();
}
latestProgress = progress;
if (progress.phase() == PackDownloader.DownloadPhase.DOWNLOADING) {
transferredBytes = Math.max(transferredBytes, Math.max(0L, progress.transferredBytes()));
transferElapsedMillis = Math.max(transferElapsedMillis, Math.max(0L, progress.elapsedMillis()));
}
long now = System.currentTimeMillis();
if (phase != progress.phase()) {
phase = progress.phase();
phaseStartedMillis = now;
deliverChat(IrisLanguage.text(
PackDownloadMessages.PROGRESS_PHASE,
MessageArgument.trusted("phase", phaseLabel(progress.phase())),
MessageArgument.untrusted("source", source)
));
}
if (progress.phase() == PackDownloader.DownloadPhase.DOWNLOADING
&& shouldSendChatProgress(progress, now)) {
lastChatMillis = now;
if (progress.totalBytes() > 0L) {
lastChatPercent = percent(progress.transferredBytes(), progress.totalBytes());
}
deliverChat(progressLine(progress));
}
} catch (RuntimeException failure) {
listenerDisabled = true;
disableHud(null);
throw failure;
}
}
synchronized void detail(String detail) {
if (finished || listenerDisabled || detail == null || detail.isBlank()) {
return;
}
try {
String[] lines = detail.split("\\R");
for (String line : lines) {
String normalized = normalizeUntrusted(redactSensitiveSource(line));
if (normalized.isBlank()) {
continue;
}
deliverChat(IrisLanguage.text(
PackDownloadMessages.PROGRESS_DETAIL,
MessageArgument.untrusted("detail", normalized)
));
}
} catch (RuntimeException failure) {
listenerDisabled = true;
disableHud(null);
throw failure;
}
}
synchronized void succeed(PackDownloader.PackInstallResult result) {
if (finished) {
return;
}
finished = true;
stopPulse();
String pack = result == null || result.key() == null || result.key().isBlank()
? source
: normalizeUntrusted(result.key());
if (result == null || !result.changed()) {
String unchanged = IrisLanguage.text(
PackDownloadMessages.PROGRESS_UNCHANGED,
MessageArgument.untrusted("pack", pack)
);
deliverChat(unchanged);
deliverTerminalHud(unchanged, BarColor.YELLOW, 1.0D);
return;
}
String complete = IrisLanguage.text(
PackDownloadMessages.PROGRESS_COMPLETE,
MessageArgument.untrusted("pack", pack),
MessageArgument.trusted("transferred", Form.fileSize(transferredBytes)),
MessageArgument.trusted("elapsed", Form.duration(transferElapsedMillis, 1))
);
deliverChat(complete);
deliverTerminalHud(complete, BarColor.GREEN, 1.0D);
if (result.restartRequired()) {
deliverChat(IrisLanguage.text(PackDownloadMessages.PROGRESS_RESTART));
}
}
synchronized void fail(Throwable failure) {
if (finished) {
return;
}
finished = true;
stopPulse();
String detail = failure == null ? "" : normalizeUntrusted(redactSensitiveSource(failure.getMessage()));
String failed = detail.isBlank()
? IrisLanguage.text(PackDownloadMessages.PROGRESS_FAILED)
: IrisLanguage.text(
PackDownloadMessages.PROGRESS_FAILED_DETAIL,
MessageArgument.untrusted("error", detail)
);
deliverChat(failed);
deliverTerminalHud(failed, BarColor.RED, 1.0D);
}
synchronized void cancel() {
if (finished) {
return;
}
finished = true;
stopPulse();
String cancelled = IrisLanguage.text(PackDownloadMessages.PROGRESS_CANCELLED);
deliverChat(cancelled);
deliverTerminalHud(cancelled, BarColor.YELLOW, 1.0D);
}
synchronized void executionComplete() {
if (!finished) {
cancel();
}
}
private void pulseHud() {
HudSnapshot snapshot;
HudSlotClaim claim;
synchronized (this) {
if (finished || hudDisabled || hudClaim == null) {
stopPulse();
return;
}
long now = System.currentTimeMillis();
if (!mayEmitAction(lastActionMillis, now)) {
return;
}
lastActionMillis = now;
snapshot = hudSnapshot(now);
claim = hudClaim;
}
boolean scheduled = J.runEntity(player, () -> renderHudPulse(claim, snapshot));
if (!scheduled) {
disableHud(null);
}
}
private void renderHudPulse(HudSlotClaim claim, HudSnapshot snapshot) {
try {
HudSurface surface = claim.resolve();
if (surface == HudSurface.ACTION_BAR) {
BukkitPlatform.hudLanes().hide(player, hudLaneId);
sender.sendAction(snapshot.line());
} else if (surface == HudSurface.BOSS_BAR) {
BukkitPlatform.hudLanes().show(
player,
hudLaneId,
snapshot.line(),
snapshot.progress(),
BarColor.BLUE,
BarStyle.SEGMENTED_20,
1_500L
);
} else {
BukkitPlatform.hudLanes().hide(player, hudLaneId);
}
} catch (RuntimeException failure) {
disableHud(failure);
}
}
private synchronized HudSnapshot hudSnapshot(long now) {
PackDownloader.DownloadProgress observed = latestProgress;
PackDownloader.DownloadPhase currentPhase = observed == null ? phase : observed.phase();
if (currentPhase == null) {
currentPhase = PackDownloader.DownloadPhase.CONNECTING;
}
long animationMillis = Math.max(0L, now - phaseStartedMillis);
PackDownloader.DownloadProgress displayed;
if (observed != null && currentPhase == PackDownloader.DownloadPhase.DOWNLOADING) {
displayed = observed;
} else {
displayed = new PackDownloader.DownloadProgress(
currentPhase,
transferredBytes,
-1L,
transferElapsedMillis,
false
);
}
double progress = displayed.totalBytes() > 0L
? Math.max(0.0D, Math.min(1.0D, (double) displayed.transferredBytes() / displayed.totalBytes()))
: indeterminateProgress(animationMillis);
return new HudSnapshot(progressLine(displayed, animationMillis), progress);
}
private void deliverChat(String message) {
if (player == null) {
sender.sendMessage(message);
return;
}
J.runEntity(player, () -> sender.sendMessage(message));
}
private synchronized void deliverTerminalHud(String message, BarColor color, double progress) {
HudSlotClaim claim = hudClaim;
hudClaim = null;
if (player == null || claim == null || hudDisabled) {
return;
}
long now = System.currentTimeMillis();
long elapsed = elapsedSince(lastActionMillis, now);
long delayMillis = Math.max(0L, ACTION_INTERVAL_MILLIS - Math.min(ACTION_INTERVAL_MILLIS, elapsed));
int delayTicks = (int) Math.ceil(delayMillis / 50.0D);
lastActionMillis = now + delayMillis;
AtomicBoolean cleaned = new AtomicBoolean();
Runnable cleanup = () -> releaseHudClaim(cleaned, claim);
Runnable retiredCleanup = () -> retireHudClaim(cleaned, claim);
Runnable display = () -> {
try {
HudSurface surface = claim.resolve();
if (surface == HudSurface.ACTION_BAR) {
BukkitPlatform.hudLanes().hide(player, hudLaneId);
sender.sendAction(message);
} else if (surface == HudSurface.BOSS_BAR) {
BukkitPlatform.hudLanes().show(
player,
hudLaneId,
message,
progress,
color,
BarStyle.SOLID,
4_000L
);
}
} finally {
if (!J.runEntity(player, cleanup, HUD_TERMINAL_TICKS, retiredCleanup)) {
retiredCleanup.run();
}
}
};
boolean scheduled = J.runEntity(player, display, delayTicks, retiredCleanup);
if (!scheduled) {
hudDisabled = true;
retiredCleanup.run();
}
}
private void disableHud(Throwable failure) {
HudSlotClaim claim;
synchronized (this) {
if (hudDisabled) {
return;
}
hudDisabled = true;
stopPulse();
claim = hudClaim;
hudClaim = null;
}
if (failure != null) {
IrisLogging.reportError("Pack download HUD disabled after a delivery failure.", failure);
}
if (player != null && claim != null) {
AtomicBoolean cleaned = new AtomicBoolean();
Runnable cleanup = () -> releaseHudClaim(cleaned, claim);
Runnable retiredCleanup = () -> retireHudClaim(cleaned, claim);
if (!J.runEntity(player, cleanup, 0, retiredCleanup)) {
retiredCleanup.run();
}
}
}
private void releaseHudClaim(AtomicBoolean cleaned, HudSlotClaim claim) {
if (!cleaned.compareAndSet(false, true)) {
return;
}
BukkitPlatform.hudLanes().hide(player, hudLaneId);
claim.release();
}
private void retireHudClaim(AtomicBoolean cleaned, HudSlotClaim claim) {
if (!cleaned.compareAndSet(false, true)) {
return;
}
BukkitPlatform.hudLanes().retire(playerId, hudLaneId);
claim.retire();
}
private synchronized void stopPulse() {
int activeTaskId = pulseTaskId;
pulseTaskId = -1;
if (activeTaskId >= 0) {
J.car(activeTaskId);
}
}
private boolean shouldSendChatProgress(PackDownloader.DownloadProgress progress, long now) {
if (lastChatMillis == Long.MIN_VALUE) {
return true;
}
if (elapsedSince(lastChatMillis, now) >= CHAT_INTERVAL_MILLIS) {
return true;
}
if (progress.totalBytes() <= 0L) {
return false;
}
return percent(progress.transferredBytes(), progress.totalBytes()) >= lastChatPercent + CHAT_PERCENT_STEP;
}
private String redactSensitiveSource(String value) {
if (value == null || sensitiveSource == null) {
return value;
}
return value.replace(sensitiveSource, source).replace(escapedSensitiveSource, source);
}
static String progressLine(PackDownloader.DownloadProgress progress) {
return progressLine(progress, progress.elapsedMillis());
}
static String progressLine(PackDownloader.DownloadProgress progress, long animationMillis) {
long transferred = Math.max(0L, progress.transferredBytes());
long rate = bytesPerSecond(transferred, progress.elapsedMillis());
if (progress.totalBytes() > 0L) {
int currentPercent = percent(transferred, progress.totalBytes());
return IrisLanguage.text(
PackDownloadMessages.PROGRESS_DETERMINATE,
MessageArgument.trusted("bar", determinateBar(currentPercent / 100.0D)),
MessageArgument.trusted("percent", currentPercent),
MessageArgument.trusted("transferred", Form.fileSize(transferred)),
MessageArgument.trusted("total", Form.fileSize(progress.totalBytes())),
MessageArgument.trusted("rate", Form.fileSize(rate))
);
}
return IrisLanguage.text(
PackDownloadMessages.PROGRESS_INDETERMINATE,
MessageArgument.trusted("bar", indeterminateBar(animationMillis)),
MessageArgument.trusted("phase", phaseLabel(progress.phase())),
MessageArgument.trusted("transferred", Form.fileSize(transferred)),
MessageArgument.trusted("rate", Form.fileSize(rate))
);
}
static String determinateBar(double progress) {
int filled = (int) Math.round(Math.max(0.0D, Math.min(1.0D, progress)) * PROGRESS_BAR_WIDTH);
StringBuilder bar = new StringBuilder(PROGRESS_BAR_WIDTH * 3 + 4);
bar.append(C.DARK_GRAY).append("[");
for (int cell = 0; cell < PROGRESS_BAR_WIDTH; cell++) {
bar.append(cell < filled ? C.GREEN : C.DARK_GRAY).append("|");
}
return bar.append(C.DARK_GRAY).append("]").toString();
}
static String indeterminateBar(long elapsedMillis) {
int travel = PROGRESS_BAR_WIDTH - INDETERMINATE_SEGMENT_WIDTH;
int cycle = travel * 2;
int step = cycle == 0 ? 0 : (int) ((Math.max(0L, elapsedMillis) / ACTION_INTERVAL_MILLIS) % cycle);
int start = step <= travel ? step : cycle - step;
StringBuilder bar = new StringBuilder(PROGRESS_BAR_WIDTH * 3 + 4);
bar.append(C.DARK_GRAY).append("[");
for (int cell = 0; cell < PROGRESS_BAR_WIDTH; cell++) {
boolean active = cell >= start && cell < start + INDETERMINATE_SEGMENT_WIDTH;
bar.append(active ? C.AQUA : C.DARK_GRAY).append("|");
}
return bar.append(C.DARK_GRAY).append("]").toString();
}
static double indeterminateProgress(long elapsedMillis) {
int travel = PROGRESS_BAR_WIDTH - INDETERMINATE_SEGMENT_WIDTH;
int cycle = travel * 2;
int step = cycle == 0 ? 0 : (int) ((Math.max(0L, elapsedMillis) / ACTION_INTERVAL_MILLIS) % cycle);
int start = step <= travel ? step : cycle - step;
return Math.max(0.0D, Math.min(1.0D,
(start + INDETERMINATE_SEGMENT_WIDTH / 2.0D) / PROGRESS_BAR_WIDTH));
}
static int percent(long transferredBytes, long totalBytes) {
if (totalBytes <= 0L) {
return 0;
}
double fraction = Math.max(0.0D, Math.min(1.0D, (double) transferredBytes / totalBytes));
return (int) Math.round(fraction * 100.0D);
}
static long bytesPerSecond(long transferredBytes, long elapsedMillis) {
if (transferredBytes <= 0L || elapsedMillis <= 0L) {
return 0L;
}
double rate = transferredBytes * 1000.0D / elapsedMillis;
return rate >= Long.MAX_VALUE ? Long.MAX_VALUE : Math.round(rate);
}
static boolean mayEmitAction(long lastEmissionMillis, long nowMillis) {
return elapsedSince(lastEmissionMillis, nowMillis) >= ACTION_INTERVAL_MILLIS;
}
static String phaseLabel(PackDownloader.DownloadPhase phase) {
return IrisLanguage.text(switch (phase) {
case CONNECTING -> PackDownloadMessages.PROGRESS_PHASE_CONNECTING;
case DOWNLOADING -> PackDownloadMessages.PROGRESS_PHASE_DOWNLOADING;
case UNPACKING -> PackDownloadMessages.PROGRESS_PHASE_UNPACKING;
case VALIDATING -> PackDownloadMessages.PROGRESS_PHASE_VALIDATING;
case PUBLISHING -> PackDownloadMessages.PROGRESS_PHASE_PUBLISHING;
});
}
private static long elapsedSince(long earlier, long later) {
if (earlier == Long.MIN_VALUE || later < earlier) {
return Long.MAX_VALUE;
}
return later - earlier;
}
private static String normalizeSensitive(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private static String escapeLocalizationUntrusted(String value) {
return LEGACY_COLOR.matcher(value).replaceAll("")
.replace("&", "")
.replace("<", "")
.replace(">", "");
}
private static String normalizeUntrusted(String value) {
if (value == null || value.isBlank()) {
return "Pack";
}
StringBuilder normalized = new StringBuilder(Math.min(value.length(), MAX_DETAIL_CHARACTERS));
for (int index = 0; index < value.length() && normalized.length() < MAX_DETAIL_CHARACTERS; index++) {
char character = value.charAt(index);
if (character >= 0x20 && character != 0x7f) {
normalized.append(character);
}
}
if (value.length() > normalized.length()) {
normalized.append("...");
}
return normalized.toString().trim();
}
private record HudSnapshot(String line, double progress) {
}
}
@@ -30,6 +30,7 @@ import art.arcane.iris.core.lifecycle.WorldLifecycleService;
import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher; import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.pack.PackDirectoryResolver;
import art.arcane.iris.core.pack.PackDownloadExecution;
import art.arcane.iris.core.pack.PackDownloader; import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidationResult;
@@ -53,6 +54,7 @@ import art.arcane.volmlib.util.io.IO;
import art.arcane.volmlib.util.json.JSONObject; import art.arcane.volmlib.util.json.JSONObject;
import art.arcane.iris.util.common.plugin.IrisService; import art.arcane.iris.util.common.plugin.IrisService;
import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.iris.util.common.scheduling.J; import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.World; import org.bukkit.World;
@@ -75,6 +77,8 @@ import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -86,14 +90,22 @@ import art.arcane.iris.core.localization.PackDownloadMessages;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
public class StudioSVC implements IrisService { public class StudioSVC implements IrisService {
public static final String WORKSPACE_NAME = "packs"; public static final String WORKSPACE_NAME = "packs";
private static final long DOWNLOAD_SHUTDOWN_POLL_SECONDS = 15L;
private static final Pattern PROJECT_NAME = Pattern.compile("[a-z0-9_-]+"); private static final Pattern PROJECT_NAME = Pattern.compile("[a-z0-9_-]+");
private static final AtomicCache<Integer> counter = new AtomicCache<>(); private static final AtomicCache<Integer> counter = new AtomicCache<>();
private final StudioTransitionQueue studioTransitions = new StudioTransitionQueue(); private final StudioTransitionQueue studioTransitions = new StudioTransitionQueue();
private final Object downloadAdmissionMonitor = new Object();
private volatile IrisProject activeProject; private volatile IrisProject activeProject;
private volatile CompletableFuture<StudioOpenCoordinator.StudioOpenResult> activeOpen; private volatile CompletableFuture<StudioOpenCoordinator.StudioOpenResult> activeOpen;
private PackDownloadExecution activeDownload;
private boolean downloadAdmissionOpen;
@Override @Override
public void onEnable() { public void onEnable() {
synchronized (downloadAdmissionMonitor) {
activeDownload = null;
downloadAdmissionOpen = true;
}
String configuredPack = IrisSettings.get().getGenerator().getDefaultWorldType(); String configuredPack = IrisSettings.get().getGenerator().getDefaultWorldType();
if (!PackDownloader.isPackPresent(getWorkspaceFolder(), configuredPack)) { if (!PackDownloader.isPackPresent(getWorkspaceFolder(), configuredPack)) {
IrisLogging.warn("Default pack '" + configuredPack IrisLogging.warn("Default pack '" + configuredPack
@@ -103,6 +115,7 @@ public class StudioSVC implements IrisService {
@Override @Override
public void onDisable() { public void onDisable() {
quiesceDownloadsForShutdown();
IrisLogging.debug("Studio Mode Active: Closing Projects"); IrisLogging.debug("Studio Mode Active: Closing Projects");
boolean stopping = IrisToolbelt.isServerStopping(); boolean stopping = IrisToolbelt.isServerStopping();
LinkedHashSet<String> worldNamesToDelete = new LinkedHashSet<>(TransientWorldCleanupSupport.collectTransientStudioWorldNames(IrisWorldStorage.levelRoot())); LinkedHashSet<String> worldNamesToDelete = new LinkedHashSet<>(TransientWorldCleanupSupport.collectTransientStudioWorldNames(IrisWorldStorage.levelRoot()));
@@ -330,44 +343,64 @@ public class StudioSVC implements IrisService {
public void downloadBuiltIn(VolmitSender sender, String key) { public void downloadBuiltIn(VolmitSender sender, String key) {
if (!PackDownloader.isBuiltInPack(key)) { if (!PackDownloader.isBuiltInPack(key)) {
sender.sendMessage("Iris only provides built-in downloads for 'overworld' and 'underworld'."); sender.sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_BUILT_IN));
return; return;
} }
runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, () -> { PackDownloadProgressReporter reporter = new PackDownloadProgressReporter(sender, key);
DownloadOutcome outcome = downloadBuiltInLocked(sender, key); runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, key, reporter, cancellation -> {
return finishStandalonePackMutation(outcome); PackDownloader.PackInstallResult result = downloadBuiltInLocked(key, cancellation, reporter);
if (result == null) {
reporter.fail(null);
return;
}
reporter.succeed(result);
}, "Failed to download built-in Iris pack '" + key + "'."); }, "Failed to download built-in Iris pack '" + key + "'.");
} }
public void downloadUrl(VolmitSender sender, String url) { public void downloadUrl(VolmitSender sender, String url) {
if (!PackDownloader.isDirectZipUrl(url)) { if (!PackDownloader.isDirectZipUrl(url)) {
sender.sendMessage("Iris requires a valid HTTP or HTTPS .zip URL."); sender.sendMessage(IrisLanguage.text(PackDownloadMessages.INVALID_URL));
return; return;
} }
runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, url, () -> { PackDownloadProgressReporter reporter = new PackDownloadProgressReporter(
DownloadOutcome outcome = DownloadOutcome.from(PackDownloader.downloadUrl( sender,
IrisLanguage.text(PackDownloadMessages.PROGRESS_SOURCE_REMOTE),
url
);
runPackMutation(sender, LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD, "remote-zip", reporter, cancellation -> {
PackDownloader.PackInstallResult result = PackDownloader.downloadUrl(
getWorkspaceFolder(), getWorkspaceFolder(),
url, url,
false, false,
sender::sendMessage reporter::detail,
)); cancellation,
return finishStandalonePackMutation(outcome); reporter
}, "Failed to download Iris pack from '" + url + "'."); );
if (result == null) {
reporter.fail(null);
return;
}
reporter.succeed(result);
}, "Failed to download Iris pack.");
} }
private DownloadOutcome downloadBuiltInLocked(VolmitSender sender, String expectedKey) throws IOException { private PackDownloader.PackInstallResult downloadBuiltInLocked(
String expectedKey,
PackDownloader.DownloadCancellation cancellation,
PackDownloadProgressReporter reporter
) throws IOException {
if (PackDownloader.isBuiltInPackPresent(getWorkspaceFolder(), expectedKey)) { if (PackDownloader.isBuiltInPackPresent(getWorkspaceFolder(), expectedKey)) {
sender.sendMessage(IrisLanguage.text(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey))); return new PackDownloader.PackInstallResult(expectedKey, false, false);
return DownloadOutcome.notChanged();
} }
PackDownloader.PackInstallResult result = PackDownloader.downloadBuiltIn( return PackDownloader.downloadBuiltIn(
getWorkspaceFolder(), getWorkspaceFolder(),
expectedKey, expectedKey,
false, false,
sender::sendMessage reporter::detail,
cancellation,
reporter
); );
return DownloadOutcome.from(result);
} }
public boolean isProjectOpen() { public boolean isProjectOpen() {
@@ -869,11 +902,18 @@ public class StudioSVC implements IrisService {
VolmitSender sender, VolmitSender sender,
LifecycleOperationCoordinator.OperationKind operationKind, LifecycleOperationCoordinator.OperationKind operationKind,
String target, String target,
PackDownloadProgressReporter reporter,
PackMutation mutation, PackMutation mutation,
String failureMessage String failureMessage
) { ) {
Runnable work = () -> { String operationTarget = target == null || target.isBlank() ? "unspecified-pack" : target.trim();
String operationTarget = target == null || target.isBlank() ? "unspecified-pack" : target.trim(); PackDownloadExecution execution;
synchronized (downloadAdmissionMonitor) {
if (!downloadAdmissionOpen) {
sender.sendMessage(IrisLanguage.text(PackDownloadMessages.SHUTTING_DOWN));
return;
}
LifecycleOperationCoordinator.Lease lease; LifecycleOperationCoordinator.Lease lease;
try { try {
lease = LifecycleOperationCoordinator.get().acquire( lease = LifecycleOperationCoordinator.get().acquire(
@@ -886,26 +926,91 @@ public class StudioSVC implements IrisService {
return; return;
} }
boolean restartRequired; execution = new PackDownloadExecution(
try { lease,
restartRequired = mutation.run(); cancellation -> executePackMutation(mutation, reporter, failureMessage, cancellation)
} catch (Throwable e) { );
IrisLogging.reportError(failureMessage, e); PackDownloadExecution trackedExecution = execution;
sender.sendMessage(failureMessage + " " + errorDetail(e)); execution.onCompletion(() -> {
return; clearActiveDownload(trackedExecution);
} finally { reporter.executionComplete();
closeLease(lease); });
} activeDownload = execution;
}
if (restartRequired) { try {
sender.sendMessage("Restart the server before using the downloaded Iris pack."); reporter.start();
Future<?> future = MultiBurst.ioBurst.submit(execution);
execution.bind(future);
} catch (Throwable e) {
try {
IrisLogging.reportError(failureMessage, e);
reporter.fail(e);
} catch (Throwable reportingFailure) {
IrisLogging.reportError("Failed to report an Iris pack download startup failure.", reportingFailure);
} finally {
execution.cancel();
} }
}; }
runOffPrimaryThread(work);
} }
private boolean finishStandalonePackMutation(DownloadOutcome outcome) { private void executePackMutation(
return outcome.changed() && outcome.restartRequired(); PackMutation mutation,
PackDownloadProgressReporter reporter,
String failureMessage,
PackDownloader.DownloadCancellation cancellation
) throws PackDownloader.PackDownloadCancelledException {
try {
mutation.run(cancellation);
} catch (PackDownloader.PackDownloadCancelledException e) {
reporter.cancel();
throw e;
} catch (PackDownloader.PackDownloadBusyException e) {
reporter.fail(e);
return;
} catch (Throwable e) {
IrisLogging.reportError(failureMessage, e);
reporter.fail(e);
}
}
public void quiesceDownloadsForShutdown() {
PackDownloadExecution execution;
synchronized (downloadAdmissionMonitor) {
downloadAdmissionOpen = false;
execution = activeDownload;
}
if (execution == null) {
return;
}
execution.cancel();
boolean interrupted = false;
boolean warned = false;
while (!execution.isComplete()) {
try {
if (!execution.await(DOWNLOAD_SHUTDOWN_POLL_SECONDS, TimeUnit.SECONDS) && !warned) {
warned = true;
IrisLogging.warn(execution.isPublishing()
? "Waiting for atomic pack publication to finish before Iris shutdown."
: "Waiting for the active pack download to cancel before Iris shutdown.");
}
} catch (InterruptedException e) {
interrupted = true;
execution.cancel();
}
}
if (interrupted) {
Thread.currentThread().interrupt();
}
}
private void clearActiveDownload(PackDownloadExecution execution) {
synchronized (downloadAdmissionMonitor) {
if (activeDownload == execution) {
activeDownload = null;
}
}
} }
private void runOffPrimaryThread(Runnable work) { private void runOffPrimaryThread(Runnable work) {
@@ -1067,9 +1172,16 @@ public class StudioSVC implements IrisService {
} }
private static void sendBusy(VolmitSender sender, LifecycleOperationCoordinator.BusyException busy) { private static void sendBusy(VolmitSender sender, LifecycleOperationCoordinator.BusyException busy) {
LifecycleOperationCoordinator.ActiveOperation operation = busy.currentOperation(); sender.sendMessage(packMutationBusyMessage(busy.currentOperation()));
sender.sendMessage("Iris pack changes are busy with " + operation.kind().name().toLowerCase(Locale.ROOT) }
+ " for '" + operation.target() + "'. Try again when it completes.");
static String packMutationBusyMessage(LifecycleOperationCoordinator.ActiveOperation operation) {
if (operation.domain() == LifecycleOperationCoordinator.Domain.PACK_MUTATION
&& operation.kind() == LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD) {
return IrisLanguage.plain(PackDownloadMessages.IN_PROGRESS);
}
return "Iris pack changes are busy with " + operation.kind().name().toLowerCase(Locale.ROOT)
+ " for '" + operation.target() + "'. Try again when it completes.";
} }
private static void closeLease(LifecycleOperationCoordinator.Lease lease) { private static void closeLease(LifecycleOperationCoordinator.Lease lease) {
@@ -1195,19 +1307,7 @@ public class StudioSVC implements IrisService {
@FunctionalInterface @FunctionalInterface
private interface PackMutation { private interface PackMutation {
boolean run() throws Exception; void run(PackDownloader.DownloadCancellation cancellation) throws Exception;
}
private record DownloadOutcome(boolean changed, boolean restartRequired) {
private static DownloadOutcome notChanged() {
return new DownloadOutcome(false, false);
}
private static DownloadOutcome from(PackDownloader.PackInstallResult result) {
return result == null
? notChanged()
: new DownloadOutcome(result.changed(), result.restartRequired());
}
} }
private enum CreationOutcome { private enum CreationOutcome {
@@ -25,6 +25,7 @@ import art.arcane.volmlib.util.io.IO;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InterruptedIOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
@@ -37,6 +38,7 @@ import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption; import java.nio.file.StandardOpenOption;
import java.time.Duration; import java.time.Duration;
import java.util.concurrent.TimeUnit;
/** /**
* Download cache helpers over the platform data folder. * Download cache helpers over the platform data folder.
@@ -48,6 +50,9 @@ public final class WebCache {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10L); private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10L);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(120L); private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(120L);
private static final int BUFFER_SIZE = 8192; private static final int BUFFER_SIZE = 8192;
private static final long PROGRESS_INTERVAL_NANOS = Duration.ofMillis(250L).toNanos();
private static final TransferProgressListener NO_TRANSFER_PROGRESS = progress -> {
};
private static volatile HttpClient client; private static volatile HttpClient client;
@@ -89,32 +94,48 @@ public final class WebCache {
} }
public static File getNonCachedFile(String name, String url, long maxBytes) { public static File getNonCachedFile(String name, String url, long maxBytes) {
return getNonCachedFile(name, url, maxBytes, NO_TRANSFER_PROGRESS);
}
public static File getNonCachedFile(String name, String url, TransferProgressListener progressListener) {
return getNonCachedFile(name, url, Long.MAX_VALUE, progressListener);
}
public static File getNonCachedFile(String name, String url, long maxBytes,
TransferProgressListener progressListener) {
String h = IO.hash(name + "*" + url); String h = IO.hash(name + "*" + url);
File f = IrisPlatforms.get().dataFile("cache", h.substring(0, 2), h.substring(3, 5), h); File f = IrisPlatforms.get().dataFile("cache", h.substring(0, 2), h.substring(3, 5), h);
IrisLogging.debug("Download " + name + " -> " + url); IrisLogging.debug("Download " + name);
return download(name, url, f, maxBytes) ? f : null; return download(name, url, f, maxBytes, progressListener) ? f : null;
} }
private static boolean download(String name, String url, File target) { private static boolean download(String name, String url, File target) {
return download(name, url, target, Long.MAX_VALUE); return download(name, url, target, Long.MAX_VALUE, NO_TRANSFER_PROGRESS);
} }
private static boolean download(String name, String url, File target, long maxBytes) { private static boolean download(String name, String url, File target, long maxBytes) {
return download(name, url, target, maxBytes, NO_TRANSFER_PROGRESS);
}
private static boolean download(String name, String url, File target, long maxBytes,
TransferProgressListener progressListener) {
if (maxBytes < 1L) { if (maxBytes < 1L) {
throw new IllegalArgumentException("Download size limit must be positive."); throw new IllegalArgumentException("Download size limit must be positive.");
} }
TransferProgressListener progress = progressListener == null ? NO_TRANSFER_PROGRESS : progressListener;
HttpRequest request = HttpRequest.newBuilder(URI.create(url)) HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(REQUEST_TIMEOUT) .timeout(REQUEST_TIMEOUT)
.GET() .GET()
.build(); .build();
Path staged = null; Path staged = null;
try { try {
checkInterrupted();
HttpResponse<InputStream> response = client() HttpResponse<InputStream> response = client()
.send(request, HttpResponse.BodyHandlers.ofInputStream()); .send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() / 100 != 2) { if (response.statusCode() / 100 != 2) {
response.body().close(); response.body().close();
IrisLogging.reportError(new IOException("HTTP " + response.statusCode() IrisLogging.reportError(new IOException("HTTP " + response.statusCode()
+ " downloading " + name + " from " + url)); + " downloading " + name));
return false; return false;
} }
long declaredBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1L); long declaredBytes = response.headers().firstValueAsLong("Content-Length").orElse(-1L);
@@ -130,20 +151,46 @@ public final class WebCache {
} }
Files.createDirectories(parent); Files.createDirectories(parent);
staged = Files.createTempFile(parent, ".download-", ".tmp"); staged = Files.createTempFile(parent, ".download-", ".tmp");
long startedNanos = System.nanoTime();
long lastProgressNanos = startedNanos;
sendProgress(progress, new TransferProgress(0L, declaredBytes, 0L, false));
long downloadedBytes = 0L;
try (InputStream in = response.body(); try (InputStream in = response.body();
OutputStream out = Files.newOutputStream(staged, StandardOpenOption.WRITE)) { OutputStream out = Files.newOutputStream(staged, StandardOpenOption.WRITE)) {
byte[] buffer = new byte[BUFFER_SIZE]; byte[] buffer = new byte[BUFFER_SIZE];
long downloadedBytes = 0L;
int read; int read;
while ((read = in.read(buffer)) != -1) { while (true) {
checkInterrupted();
read = in.read(buffer);
if (read == -1) {
break;
}
checkInterrupted();
if (read > maxBytes - downloadedBytes) { if (read > maxBytes - downloadedBytes) {
throw new IOException("Download exceeds the size limit for " + name + "."); throw new IOException("Download exceeds the size limit for " + name + ".");
} }
out.write(buffer, 0, read); out.write(buffer, 0, read);
downloadedBytes += read; downloadedBytes += read;
long currentNanos = System.nanoTime();
if (currentNanos - lastProgressNanos >= PROGRESS_INTERVAL_NANOS) {
sendProgress(progress, new TransferProgress(
downloadedBytes,
declaredBytes,
elapsedMillis(startedNanos, currentNanos),
false
));
lastProgressNanos = currentNanos;
}
} }
out.flush(); out.flush();
} }
sendProgress(progress, new TransferProgress(
downloadedBytes,
declaredBytes,
elapsedMillis(startedNanos),
true
));
checkInterrupted();
try { try {
Files.move(staged, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); Files.move(staged, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException unsupported) { } catch (AtomicMoveNotSupportedException unsupported) {
@@ -151,12 +198,23 @@ public final class WebCache {
} }
staged = null; staged = null;
return true; return true;
} catch (InterruptedIOException e) {
if (Thread.currentThread().isInterrupted()) {
IrisLogging.debug("Download interrupted for " + name);
} else {
IrisLogging.reportError(e);
}
return false;
} catch (IOException e) { } catch (IOException e) {
if (Thread.currentThread().isInterrupted()) {
IrisLogging.debug("Download interrupted for " + name);
return false;
}
IrisLogging.reportError(e); IrisLogging.reportError(e);
return false; return false;
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
IrisLogging.reportError(e); IrisLogging.debug("Download interrupted for " + name);
return false; return false;
} finally { } finally {
if (staged != null) { if (staged != null) {
@@ -169,6 +227,28 @@ public final class WebCache {
} }
} }
private static void checkInterrupted() throws InterruptedIOException {
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedIOException("Download interrupted.");
}
}
private static long elapsedMillis(long startedNanos) {
return elapsedMillis(startedNanos, System.nanoTime());
}
private static long elapsedMillis(long startedNanos, long currentNanos) {
return TimeUnit.NANOSECONDS.toMillis(Math.max(0L, currentNanos - startedNanos));
}
private static void sendProgress(TransferProgressListener listener, TransferProgress progress) {
try {
listener.onProgress(progress);
} catch (RuntimeException exception) {
IrisLogging.reportError("Download progress delivery failed", exception);
}
}
private static HttpClient client() { private static HttpClient client() {
HttpClient current = client; HttpClient current = client;
if (current != null) { if (current != null) {
@@ -184,4 +264,12 @@ public final class WebCache {
return client; return client;
} }
} }
public record TransferProgress(long transferredBytes, long contentLength, long elapsedMillis, boolean complete) {
}
@FunctionalInterface
public interface TransferProgressListener {
void onProgress(TransferProgress progress);
}
} }
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash gestartet: {chunks} Chunks um 0,0 in Puffern (Welt bleibt unverändert), threads={threads} mode={mode}", "iris.runtime.golden.started": "GoldenHash gestartet: {chunks} Chunks um 0,0 in Puffern (Welt bleibt unverändert), threads={threads} mode={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] Chunk {x},{z} gehasht", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] Chunk {x},{z} gehasht",
"iris.runtime.golden.chunk_failed": "Chunk {x},{z} FEHLGESCHLAGEN: {type}", "iris.runtime.golden.chunk_failed": "Chunk {x},{z} FEHLGESCHLAGEN: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6PACK-DOWNLOAD§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aIris-Pack '{pack}' installiert§8 | §f{transferred}§7 in §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eIris-Pack '{pack}' ist bereits installiert.",
"iris.runtime.pack_download.progress.failed": "§cDownload des Iris-Packs fehlgeschlagen.§7 Prüfe die Download-Details oben und versuche es erneut.",
"iris.runtime.pack_download.progress.failed_detail": "§cDownload des Iris-Packs fehlgeschlagen.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eDownload des Iris-Packs vor der Veröffentlichung abgebrochen.",
"iris.runtime.pack_download.progress.restart": "§6Neustart erforderlich§8 | §7Starte den Server neu, bevor du mit diesem Pack eine Welt erstellst oder ersetzt.",
"iris.runtime.pack_download.progress.phase.connecting": "Verbindung wird hergestellt",
"iris.runtime.pack_download.progress.phase.downloading": "Herunterladen",
"iris.runtime.pack_download.progress.phase.unpacking": "Entpacken",
"iris.runtime.pack_download.progress.phase.validating": "Validieren",
"iris.runtime.pack_download.progress.phase.publishing": "Veröffentlichen",
"iris.runtime.pack_download.progress.source.remote": "Remote-ZIP-Datei",
"iris.runtime.pack_download.invalid_source": "§cWähle genau eine Quelle: /iris download pack=overworld, /iris download pack=underworld oder /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cIris benötigt eine gültige HTTP- oder HTTPS-URL zu einer .zip-Datei.",
"iris.runtime.pack_download.invalid_built_in": "§cIris bietet integrierte Downloads nur für 'overworld' und 'underworld' an.",
"iris.runtime.pack_download.shutting_down": "§eIris wird heruntergefahren und nimmt keine Pack-Downloads mehr an.",
"iris.runtime.pack_download.downloading": "{url} wird heruntergeladen", "iris.runtime.pack_download.downloading": "{url} wird heruntergeladen",
"iris.runtime.pack_download.failed_to_find": "Unter {url} wurde kein Pack gefunden", "iris.runtime.pack_download.failed_to_find": "Unter {url} wurde kein Pack gefunden",
"iris.runtime.pack_download.unpacking": "{repository} wird entpackt", "iris.runtime.pack_download.unpacking": "{repository} wird entpackt",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "Ein anderer Pack verwendet bereits den Schlüssel {key}. Import fehlgeschlagen!", "iris.runtime.pack_download.pack_key_conflict": "Ein anderer Pack verwendet bereits den Schlüssel {key}. Import fehlgeschlagen!",
"iris.runtime.pack_download.acquired": "{name} erfolgreich abgerufen.", "iris.runtime.pack_download.acquired": "{name} erfolgreich abgerufen.",
"iris.runtime.pack_download.already_installed": "Pack {key} ist bereits installiert, Download wird übersprungen.", "iris.runtime.pack_download.already_installed": "Pack {key} ist bereits installiert, Download wird übersprungen.",
"iris.runtime.pack_download.in_progress": "Ein anderer Iris-Pack-Download läuft bereits. Warte, bis er abgeschlossen ist, bevor du es erneut versuchst.",
"iris.runtime.pack_download.validation_failed": "Pack '{pack}' hat die Validierung nicht bestanden; Welt- und Studio-Erstellung werden verweigert. Gründe:", "iris.runtime.pack_download.validation_failed": "Pack '{pack}' hat die Validierung nicht bestanden; Welt- und Studio-Erstellung werden verweigert. Gründe:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash iniciado: {chunks} chunks alrededor de 0,0 en búferes (sin modificar el mundo), hilos={threads} modo={mode}", "iris.runtime.golden.started": "GoldenHash iniciado: {chunks} chunks alrededor de 0,0 en búferes (sin modificar el mundo), hilos={threads} modo={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] hash calculado para el chunk {x},{z}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] hash calculado para el chunk {x},{z}",
"iris.runtime.golden.chunk_failed": "Chunk {x},{z} FALLÓ: {type}", "iris.runtime.golden.chunk_failed": "Chunk {x},{z} FALLÓ: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6DESCARGA DE PACK§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aEl pack de Iris '{pack}' está instalado§8 | §f{transferred}§7 en §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eEl pack de Iris '{pack}' ya está instalado.",
"iris.runtime.pack_download.progress.failed": "§cLa descarga del pack de Iris ha fallado.§7 Revisa los detalles de la descarga de arriba y vuelve a intentarlo.",
"iris.runtime.pack_download.progress.failed_detail": "§cLa descarga del pack de Iris ha fallado.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eLa descarga del pack de Iris se canceló antes de publicarlo.",
"iris.runtime.pack_download.progress.restart": "§6Reinicio necesario§8 | §7Reinicia el servidor antes de crear o sustituir un mundo con este pack.",
"iris.runtime.pack_download.progress.phase.connecting": "Conectando",
"iris.runtime.pack_download.progress.phase.downloading": "Descargando",
"iris.runtime.pack_download.progress.phase.unpacking": "Descomprimiendo",
"iris.runtime.pack_download.progress.phase.validating": "Validando",
"iris.runtime.pack_download.progress.phase.publishing": "Publicando",
"iris.runtime.pack_download.progress.source.remote": "ZIP remoto",
"iris.runtime.pack_download.invalid_source": "§cElige exactamente una fuente: /iris download pack=overworld, /iris download pack=underworld o /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cIris requiere una URL HTTP o HTTPS válida que apunte a un archivo .zip.",
"iris.runtime.pack_download.invalid_built_in": "§cIris solo ofrece descargas integradas para 'overworld' y 'underworld'.",
"iris.runtime.pack_download.shutting_down": "§eIris se está cerrando y no acepta descargas de packs.",
"iris.runtime.pack_download.downloading": "Descargando {url}", "iris.runtime.pack_download.downloading": "Descargando {url}",
"iris.runtime.pack_download.failed_to_find": "No se encontró el pack en {url}", "iris.runtime.pack_download.failed_to_find": "No se encontró el pack en {url}",
"iris.runtime.pack_download.unpacking": "Descomprimiendo {repository}", "iris.runtime.pack_download.unpacking": "Descomprimiendo {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "Otro pack usa la clave {key}. ¡La importación falló!", "iris.runtime.pack_download.pack_key_conflict": "Otro pack usa la clave {key}. ¡La importación falló!",
"iris.runtime.pack_download.acquired": "{name} se obtuvo correctamente.", "iris.runtime.pack_download.acquired": "{name} se obtuvo correctamente.",
"iris.runtime.pack_download.already_installed": "El pack {key} ya está instalado, se omite la descarga.", "iris.runtime.pack_download.already_installed": "El pack {key} ya está instalado, se omite la descarga.",
"iris.runtime.pack_download.in_progress": "Ya hay otra descarga de un pack de Iris en curso. Espera a que termine antes de volver a intentarlo.",
"iris.runtime.pack_download.validation_failed": "El pack '{pack}' no superó la validación; se rechazará la creación de mundos y de Studio. Motivos:", "iris.runtime.pack_download.validation_failed": "El pack '{pack}' no superó la validación; se rechazará la creación de mundos y de Studio. Motivos:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash alkoi: {chunks} Pyörii ympäri 0,0 puskurissa (maailmassa koskemattomana), langat ={threads} tila ={mode}", "iris.runtime.golden.started": "GoldenHash alkoi: {chunks} Pyörii ympäri 0,0 puskurissa (maailmassa koskemattomana), langat ={threads} tila ={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] pala {x},{z} hasheed", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] pala {x},{z} hasheed",
"iris.runtime.golden.chunk_failed": "Liha {x},{z} epäonnistui: {type}", "iris.runtime.golden.chunk_failed": "Liha {x},{z} epäonnistui: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6PAKETIN LATAUS§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aIris-paketti '{pack}' asennettu§8 | §f{transferred}§7 ajassa §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eIris-paketti '{pack}' on jo asennettu.",
"iris.runtime.pack_download.progress.failed": "§cIris-paketin lataus epäonnistui.§7 Tarkista yllä olevat lataustiedot ja yritä uudelleen.",
"iris.runtime.pack_download.progress.failed_detail": "§cIris-paketin lataus epäonnistui.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eIris-paketin lataus peruutettiin ennen julkaisua.",
"iris.runtime.pack_download.progress.restart": "§6Uudelleenkäynnistys vaaditaan§8 | §7Käynnistä palvelin uudelleen ennen kuin luot tai korvaat maailman tällä paketilla.",
"iris.runtime.pack_download.progress.phase.connecting": "Yhdistetään",
"iris.runtime.pack_download.progress.phase.downloading": "Ladataan",
"iris.runtime.pack_download.progress.phase.unpacking": "Puretaan",
"iris.runtime.pack_download.progress.phase.validating": "Tarkistetaan",
"iris.runtime.pack_download.progress.phase.publishing": "Julkaistaan",
"iris.runtime.pack_download.progress.source.remote": "Etä-ZIP-tiedosto",
"iris.runtime.pack_download.invalid_source": "§cValitse täsmälleen yksi lähde: /iris download pack=overworld, /iris download pack=underworld tai /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cIris vaatii kelvollisen HTTP- tai HTTPS-osoitteen .zip-tiedostoon.",
"iris.runtime.pack_download.invalid_built_in": "§cIriksen sisäiset lataukset ovat saatavilla vain paketeille 'overworld' ja 'underworld'.",
"iris.runtime.pack_download.shutting_down": "§eIristä sammutetaan, eikä se ota vastaan pakettien latauksia.",
"iris.runtime.pack_download.downloading": "Noudetaan {url}", "iris.runtime.pack_download.downloading": "Noudetaan {url}",
"iris.runtime.pack_download.failed_to_find": "Pakkausta ei löytynyt {url}", "iris.runtime.pack_download.failed_to_find": "Pakkausta ei löytynyt {url}",
"iris.runtime.pack_download.unpacking": "Purkaminen {repository}", "iris.runtime.pack_download.unpacking": "Purkaminen {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "Toinen pakkaus käyttää avainta {key}. Tuonti epäonnistui!", "iris.runtime.pack_download.pack_key_conflict": "Toinen pakkaus käyttää avainta {key}. Tuonti epäonnistui!",
"iris.runtime.pack_download.acquired": "Onnistunut hankinta {name}.", "iris.runtime.pack_download.acquired": "Onnistunut hankinta {name}.",
"iris.runtime.pack_download.already_installed": "Pack {key} on jo asennettu, lataus ohitetaan.", "iris.runtime.pack_download.already_installed": "Pack {key} on jo asennettu, lataus ohitetaan.",
"iris.runtime.pack_download.in_progress": "Toinen Iris-paketin lataus on jo käynnissä. Odota sen valmistumista ennen kuin yrität uudelleen.",
"iris.runtime.pack_download.validation_failed": "Pakkaus{pack}' Epäonnistunut validointi; maailma ja Studio luominen hylätään. Perusteet:", "iris.runtime.pack_download.validation_failed": "Pakkaus{pack}' Epäonnistunut validointi; maailma ja Studio luominen hylätään. Perusteet:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash démarré : {chunks} chunks autour de 0,0 dans des tampons (monde inchangé), threads={threads} mode={mode}", "iris.runtime.golden.started": "GoldenHash démarré : {chunks} chunks autour de 0,0 dans des tampons (monde inchangé), threads={threads} mode={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] hash calculé pour le chunk {x},{z}", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] hash calculé pour le chunk {x},{z}",
"iris.runtime.golden.chunk_failed": "Chunk {x},{z} ÉCHEC : {type}", "iris.runtime.golden.chunk_failed": "Chunk {x},{z} ÉCHEC : {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6TÉLÉCHARGEMENT DU PACK§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aLe pack Iris '{pack}' est installé§8 | §f{transferred}§7 en §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eLe pack Iris '{pack}' est déjà installé.",
"iris.runtime.pack_download.progress.failed": "§cÉchec du téléchargement du pack Iris.§7 Consultez les détails du téléchargement ci-dessus et réessayez.",
"iris.runtime.pack_download.progress.failed_detail": "§cÉchec du téléchargement du pack Iris.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eTéléchargement du pack Iris annulé avant sa publication.",
"iris.runtime.pack_download.progress.restart": "§6Redémarrage requis§8 | §7Redémarrez le serveur avant de créer ou remplacer un monde avec ce pack.",
"iris.runtime.pack_download.progress.phase.connecting": "Connexion",
"iris.runtime.pack_download.progress.phase.downloading": "Téléchargement",
"iris.runtime.pack_download.progress.phase.unpacking": "Décompression",
"iris.runtime.pack_download.progress.phase.validating": "Validation",
"iris.runtime.pack_download.progress.phase.publishing": "Publication",
"iris.runtime.pack_download.progress.source.remote": "ZIP distant",
"iris.runtime.pack_download.invalid_source": "§cChoisissez exactement une source : /iris download pack=overworld, /iris download pack=underworld ou /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cIris nécessite une URL HTTP ou HTTPS valide vers un fichier .zip.",
"iris.runtime.pack_download.invalid_built_in": "§cIris ne propose de téléchargement intégré que pour 'overworld' et 'underworld'.",
"iris.runtime.pack_download.shutting_down": "§eIris est en cours darrêt et naccepte plus de téléchargements de packs.",
"iris.runtime.pack_download.downloading": "Téléchargement de {url}", "iris.runtime.pack_download.downloading": "Téléchargement de {url}",
"iris.runtime.pack_download.failed_to_find": "Pack introuvable dans {url}", "iris.runtime.pack_download.failed_to_find": "Pack introuvable dans {url}",
"iris.runtime.pack_download.unpacking": "Décompression de {repository}", "iris.runtime.pack_download.unpacking": "Décompression de {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "Un autre pack utilise la clé {key}. Échec de l'importation !", "iris.runtime.pack_download.pack_key_conflict": "Un autre pack utilise la clé {key}. Échec de l'importation !",
"iris.runtime.pack_download.acquired": "{name} obtenu avec succès.", "iris.runtime.pack_download.acquired": "{name} obtenu avec succès.",
"iris.runtime.pack_download.already_installed": "Le pack {key} est déjà installé, téléchargement ignoré.", "iris.runtime.pack_download.already_installed": "Le pack {key} est déjà installé, téléchargement ignoré.",
"iris.runtime.pack_download.in_progress": "Un autre téléchargement de pack Iris est déjà en cours. Attendez quil se termine avant de réessayer.",
"iris.runtime.pack_download.validation_failed": "Le pack '{pack}' a échoué à la validation ; la création de mondes et de Studio sera refusée. Raisons :", "iris.runtime.pack_download.validation_failed": "Le pack '{pack}' a échoué à la validation ; la création de mondes et de Studio sera refusée. Raisons :",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHashהתחיל:{chunks}צ'אנקים סביב 0,0 ב מאגרים (עולם שלא ניתן לעשות)threads={threads} mode={mode}", "iris.runtime.golden.started": "GoldenHashהתחיל:{chunks}צ'אנקים סביב 0,0 ב מאגרים (עולם שלא ניתן לעשות)threads={threads} mode={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] גוש נתח {x},{z} תגית:", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] גוש נתח {x},{z} תגית:",
"iris.runtime.golden.chunk_failed": "צ'אנק {x},{z} נכשל: {type}", "iris.runtime.golden.chunk_failed": "צ'אנק {x},{z} נכשל: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6הורדת חבילה§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aחבילת Iris '{pack}' הותקנה§8 | §f{transferred}§7 בתוך §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eחבילת Iris '{pack}' כבר מותקנת.",
"iris.runtime.pack_download.progress.failed": "§cהורדת חבילת Iris נכשלה.§7 יש לעיין בפרטי ההורדה שלעיל ולנסות שוב.",
"iris.runtime.pack_download.progress.failed_detail": "§cהורדת חבילת Iris נכשלה.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eהורדת חבילת Iris בוטלה לפני הפרסום.",
"iris.runtime.pack_download.progress.restart": "§6נדרשת הפעלה מחדש§8 | §7יש להפעיל מחדש את השרת לפני יצירה או החלפה של עולם באמצעות חבילה זו.",
"iris.runtime.pack_download.progress.phase.connecting": "מתבצע חיבור",
"iris.runtime.pack_download.progress.phase.downloading": "מתבצעת הורדה",
"iris.runtime.pack_download.progress.phase.unpacking": "מתבצע חילוץ",
"iris.runtime.pack_download.progress.phase.validating": "מתבצע אימות",
"iris.runtime.pack_download.progress.phase.publishing": "מתבצע פרסום",
"iris.runtime.pack_download.progress.source.remote": "קובץ ZIP מרוחק",
"iris.runtime.pack_download.invalid_source": "§cיש לבחור מקור אחד בלבד: /iris download pack=overworld, /iris download pack=underworld או /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cIris דורש כתובת HTTP או HTTPS חוקית לקובץ .zip.",
"iris.runtime.pack_download.invalid_built_in": "§cIris מספק הורדות מובנות רק עבור 'overworld' ו-'underworld'.",
"iris.runtime.pack_download.shutting_down": "§eIris נכבה כעת ואינו מקבל הורדות של חבילות.",
"iris.runtime.pack_download.downloading": "הורדה {url}", "iris.runtime.pack_download.downloading": "הורדה {url}",
"iris.runtime.pack_download.failed_to_find": "נכשל למצוא חבילות {url}", "iris.runtime.pack_download.failed_to_find": "נכשל למצוא חבילות {url}",
"iris.runtime.pack_download.unpacking": "חבילות חילוץ {repository}", "iris.runtime.pack_download.unpacking": "חבילות חילוץ {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "חבילה נוספת משתמשת במפתח {key}. ייבוא נכשל!", "iris.runtime.pack_download.pack_key_conflict": "חבילה נוספת משתמשת במפתח {key}. ייבוא נכשל!",
"iris.runtime.pack_download.acquired": "נרכשה בהצלחה {name}.", "iris.runtime.pack_download.acquired": "נרכשה בהצלחה {name}.",
"iris.runtime.pack_download.already_installed": "החבילה {key} כבר מותקנת, ההורדה מדולגת.", "iris.runtime.pack_download.already_installed": "החבילה {key} כבר מותקנת, ההורדה מדולגת.",
"iris.runtime.pack_download.in_progress": "הורדה אחרת של חבילת Iris כבר מתבצעת. יש להמתין לסיומה לפני ניסיון נוסף.",
"iris.runtime.pack_download.validation_failed": "Pack »{pack}\"התאימות הכושל; יצירת העולם והסטודיו לא תסרב. סיבות:", "iris.runtime.pack_download.validation_failed": "Pack »{pack}\"התאימות הכושל; יצירת העולם והסטודיו לא תסרב. סיבות:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash avviato: {chunks} chunk attorno a 0,0 nei buffer (mondo invariato), threads={threads} mode={mode}", "iris.runtime.golden.started": "GoldenHash avviato: {chunks} chunk attorno a 0,0 nei buffer (mondo invariato), threads={threads} mode={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] hash del chunk {x},{z} calcolato", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] hash del chunk {x},{z} calcolato",
"iris.runtime.golden.chunk_failed": "Chunk {x},{z} NON RIUSCITO: {type}", "iris.runtime.golden.chunk_failed": "Chunk {x},{z} NON RIUSCITO: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6DOWNLOAD DEL PACK§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aPack Iris '{pack}' installato§8 | §f{transferred}§7 in §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eIl pack Iris '{pack}' è già installato.",
"iris.runtime.pack_download.progress.failed": "§cDownload del pack Iris non riuscito.§7 Controlla i dettagli del download qui sopra e riprova.",
"iris.runtime.pack_download.progress.failed_detail": "§cDownload del pack Iris non riuscito.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eDownload del pack Iris annullato prima della pubblicazione.",
"iris.runtime.pack_download.progress.restart": "§6Riavvio necessario§8 | §7Riavvia il server prima di creare o sostituire un mondo con questo pack.",
"iris.runtime.pack_download.progress.phase.connecting": "Connessione",
"iris.runtime.pack_download.progress.phase.downloading": "Download",
"iris.runtime.pack_download.progress.phase.unpacking": "Estrazione",
"iris.runtime.pack_download.progress.phase.validating": "Validazione",
"iris.runtime.pack_download.progress.phase.publishing": "Pubblicazione",
"iris.runtime.pack_download.progress.source.remote": "ZIP remoto",
"iris.runtime.pack_download.invalid_source": "§cScegli esattamente una sorgente: /iris download pack=overworld, /iris download pack=underworld oppure /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cIris richiede un URL HTTP o HTTPS valido per un file .zip.",
"iris.runtime.pack_download.invalid_built_in": "§cIris offre download integrati solo per 'overworld' e 'underworld'.",
"iris.runtime.pack_download.shutting_down": "§eIris si sta arrestando e non accetta download di pack.",
"iris.runtime.pack_download.downloading": "Scaricamento {url}", "iris.runtime.pack_download.downloading": "Scaricamento {url}",
"iris.runtime.pack_download.failed_to_find": "Impossibile trovare il Pack in {url}", "iris.runtime.pack_download.failed_to_find": "Impossibile trovare il Pack in {url}",
"iris.runtime.pack_download.unpacking": "Disimballaggio {repository}", "iris.runtime.pack_download.unpacking": "Disimballaggio {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "Un altro Pack usa già la chiave {key}. Importazione non riuscita!", "iris.runtime.pack_download.pack_key_conflict": "Un altro Pack usa già la chiave {key}. Importazione non riuscita!",
"iris.runtime.pack_download.acquired": "{name} acquisito correttamente.", "iris.runtime.pack_download.acquired": "{name} acquisito correttamente.",
"iris.runtime.pack_download.already_installed": "Il pack {key} è già installato, download saltato.", "iris.runtime.pack_download.already_installed": "Il pack {key} è già installato, download saltato.",
"iris.runtime.pack_download.in_progress": "È già in corso il download di un altro pack Iris. Attendi che termini prima di riprovare.",
"iris.runtime.pack_download.validation_failed": "Il Pack '{pack}' non ha superato la convalida; la creazione di mondi e Studio verrà rifiutata. Motivi:", "iris.runtime.pack_download.validation_failed": "Il Pack '{pack}' non ha superato la convalida; la creazione di mondi e Studio verrà rifiutata. Motivi:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash を開始しました: 0,0 周辺の {chunks} チャンクをバッファー内で処理します(ワールドは変更しません)。スレッド={threads} モード={mode}", "iris.runtime.golden.started": "GoldenHash を開始しました: 0,0 周辺の {chunks} チャンクをバッファー内で処理します(ワールドは変更しません)。スレッド={threads} モード={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] チャンク {x},{z} をハッシュ化しました", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] チャンク {x},{z} をハッシュ化しました",
"iris.runtime.golden.chunk_failed": "チャンク {x},{z} 失敗: {type}", "iris.runtime.golden.chunk_failed": "チャンク {x},{z} 失敗: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6パックをダウンロード§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aIris パック '{pack}' をインストールしました§8 | §f{transferred}§7、所要時間 §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eIris パック '{pack}' はすでにインストールされています。",
"iris.runtime.pack_download.progress.failed": "§cIris パックのダウンロードに失敗しました。§7 上記のダウンロード詳細を確認して、もう一度お試しください。",
"iris.runtime.pack_download.progress.failed_detail": "§cIris パックのダウンロードに失敗しました。§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eIris パックのダウンロードは公開前にキャンセルされました。",
"iris.runtime.pack_download.progress.restart": "§6再起動が必要です§8 | §7このパックでワールドを作成または置換する前に、サーバーを再起動してください。",
"iris.runtime.pack_download.progress.phase.connecting": "接続中",
"iris.runtime.pack_download.progress.phase.downloading": "ダウンロード中",
"iris.runtime.pack_download.progress.phase.unpacking": "展開中",
"iris.runtime.pack_download.progress.phase.validating": "検証中",
"iris.runtime.pack_download.progress.phase.publishing": "公開中",
"iris.runtime.pack_download.progress.source.remote": "リモート ZIP",
"iris.runtime.pack_download.invalid_source": "§cダウンロード元を1つだけ選択してください: /iris download pack=overworld、/iris download pack=underworld、または /iris download link=zip-url›。",
"iris.runtime.pack_download.invalid_url": "§cIris には .zip ファイルを指す有効な HTTP または HTTPS URL が必要です。",
"iris.runtime.pack_download.invalid_built_in": "§cIris の組み込みダウンロードは 'overworld' と 'underworld' のみです。",
"iris.runtime.pack_download.shutting_down": "§eIris はシャットダウン中のため、パックのダウンロードを受け付けていません。",
"iris.runtime.pack_download.downloading": "{url} をダウンロードしています", "iris.runtime.pack_download.downloading": "{url} をダウンロードしています",
"iris.runtime.pack_download.failed_to_find": "{url} にパックが見つかりませんでした", "iris.runtime.pack_download.failed_to_find": "{url} にパックが見つかりませんでした",
"iris.runtime.pack_download.unpacking": "{repository} を展開しています", "iris.runtime.pack_download.unpacking": "{repository} を展開しています",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "別のパックがキー {key} を使用しています。インポートに失敗しました!", "iris.runtime.pack_download.pack_key_conflict": "別のパックがキー {key} を使用しています。インポートに失敗しました!",
"iris.runtime.pack_download.acquired": "{name} を取得しました。", "iris.runtime.pack_download.acquired": "{name} を取得しました。",
"iris.runtime.pack_download.already_installed": "パック {key} は既にインストールされているため、ダウンロードをスキップします。", "iris.runtime.pack_download.already_installed": "パック {key} は既にインストールされているため、ダウンロードをスキップします。",
"iris.runtime.pack_download.in_progress": "別の Iris パックのダウンロードがすでに進行中です。完了してからもう一度お試しください。",
"iris.runtime.pack_download.validation_failed": "パック '{pack}' は検証に失敗しました。ワールドと Studio の作成を拒否します。理由:", "iris.runtime.pack_download.validation_failed": "パック '{pack}' は検証に失敗しました。ワールドと Studio の作成を拒否します。理由:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash 시작 : {chunks} 청크 주위에 0,0 버퍼 (세계 변경 없음), 스레드 ={threads} 모드 ={mode}", "iris.runtime.golden.started": "GoldenHash 시작 : {chunks} 청크 주위에 0,0 버퍼 (세계 변경 없음), 스레드 ={threads} 모드 ={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] 청크 {x},{z} 청크", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] 청크 {x},{z} 청크",
"iris.runtime.golden.chunk_failed": "주 메뉴 {x},{z} 실패: {type}", "iris.runtime.golden.chunk_failed": "주 메뉴 {x},{z} 실패: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6팩 다운로드§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aIris 팩 '{pack}' 설치 완료§8 | §f{transferred}§7, 소요 시간 §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eIris 팩 '{pack}'은(는) 이미 설치되어 있습니다.",
"iris.runtime.pack_download.progress.failed": "§cIris 팩 다운로드에 실패했습니다.§7 위의 다운로드 세부 정보를 확인한 후 다시 시도하세요.",
"iris.runtime.pack_download.progress.failed_detail": "§cIris 팩 다운로드에 실패했습니다.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eIris 팩 다운로드가 게시 전에 취소되었습니다.",
"iris.runtime.pack_download.progress.restart": "§6재시작 필요§8 | §7이 팩으로 월드를 생성하거나 교체하기 전에 서버를 재시작하세요.",
"iris.runtime.pack_download.progress.phase.connecting": "연결 중",
"iris.runtime.pack_download.progress.phase.downloading": "다운로드 중",
"iris.runtime.pack_download.progress.phase.unpacking": "압축 해제 중",
"iris.runtime.pack_download.progress.phase.validating": "검증 중",
"iris.runtime.pack_download.progress.phase.publishing": "게시 중",
"iris.runtime.pack_download.progress.source.remote": "원격 ZIP",
"iris.runtime.pack_download.invalid_source": "§c다운로드 소스를 하나만 선택하세요: /iris download pack=overworld, /iris download pack=underworld 또는 /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cIris에는 .zip 파일을 가리키는 유효한 HTTP 또는 HTTPS URL이 필요합니다.",
"iris.runtime.pack_download.invalid_built_in": "§cIris는 'overworld'와 'underworld'에 대해서만 기본 제공 다운로드를 지원합니다.",
"iris.runtime.pack_download.shutting_down": "§eIris가 종료 중이므로 팩 다운로드를 받을 수 없습니다.",
"iris.runtime.pack_download.downloading": "다운로드 {url}", "iris.runtime.pack_download.downloading": "다운로드 {url}",
"iris.runtime.pack_download.failed_to_find": "팩을 찾기 위해 실패 {url}", "iris.runtime.pack_download.failed_to_find": "팩을 찾기 위해 실패 {url}",
"iris.runtime.pack_download.unpacking": "옵션 정보 {repository}", "iris.runtime.pack_download.unpacking": "옵션 정보 {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "다른 팩이 키 {key}을(를) 사용합니다. 가져오기에 실패했습니다!", "iris.runtime.pack_download.pack_key_conflict": "다른 팩이 키 {key}을(를) 사용합니다. 가져오기에 실패했습니다!",
"iris.runtime.pack_download.acquired": "성공적으로 취득 {name}.", "iris.runtime.pack_download.acquired": "성공적으로 취득 {name}.",
"iris.runtime.pack_download.already_installed": "팩 {key}이(가) 이미 설치되어 있어 다운로드를 건너뜁니다.", "iris.runtime.pack_download.already_installed": "팩 {key}이(가) 이미 설치되어 있어 다운로드를 건너뜁니다.",
"iris.runtime.pack_download.in_progress": "다른 Iris 팩 다운로드가 이미 진행 중입니다. 완료될 때까지 기다린 후 다시 시도하세요.",
"iris.runtime.pack_download.validation_failed": "팩 '{pack}' 유효성 검사; 세계 및 스튜디오 생성은 거부됩니다. 이유:", "iris.runtime.pack_download.validation_failed": "팩 '{pack}' 유효성 검사; 세계 및 스튜디오 생성은 거부됩니다. 이유:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash pradėjo: {chunks} chunkai aplink 0,0 taukšuose (nepaliestas pasaulis), siūlai ={threads} režimas ={mode}", "iris.runtime.golden.started": "GoldenHash pradėjo: {chunks} chunkai aplink 0,0 taukšuose (nepaliestas pasaulis), siūlai ={threads} režimas ={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] unit description in lists {x},{z} hash", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] unit description in lists {x},{z} hash",
"iris.runtime.golden.chunk_failed": "Šriftas {x},{z} nepavyko: {type}", "iris.runtime.golden.chunk_failed": "Šriftas {x},{z} nepavyko: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6PAKETO ATSISIUNTIMAS§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§a„Iris“ paketas '{pack}' įdiegtas§8 | §f{transferred}§7 per §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§e„Iris“ paketas '{pack}' jau įdiegtas.",
"iris.runtime.pack_download.progress.failed": "§cNepavyko atsisiųsti „Iris“ paketo.§7 Peržiūrėkite aukščiau pateiktą atsisiuntimo informaciją ir bandykite dar kartą.",
"iris.runtime.pack_download.progress.failed_detail": "§cNepavyko atsisiųsti „Iris“ paketo.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§e„Iris“ paketo atsisiuntimas atšauktas prieš publikavimą.",
"iris.runtime.pack_download.progress.restart": "§6Reikia paleisti iš naujo§8 | §7Prieš kurdami arba pakeisdami pasaulį šiuo paketu, paleiskite serverį iš naujo.",
"iris.runtime.pack_download.progress.phase.connecting": "Jungiamasi",
"iris.runtime.pack_download.progress.phase.downloading": "Atsisiunčiama",
"iris.runtime.pack_download.progress.phase.unpacking": "Išpakuojama",
"iris.runtime.pack_download.progress.phase.validating": "Tikrinama",
"iris.runtime.pack_download.progress.phase.publishing": "Publikuojama",
"iris.runtime.pack_download.progress.source.remote": "Nuotolinis ZIP failas",
"iris.runtime.pack_download.invalid_source": "§cPasirinkite tik vieną šaltinį: /iris download pack=overworld, /iris download pack=underworld arba /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cIris reikia galiojančio HTTP arba HTTPS .zip failo URL.",
"iris.runtime.pack_download.invalid_built_in": "§cIris integruotai leidžia atsisiųsti tik 'overworld' ir 'underworld'.",
"iris.runtime.pack_download.shutting_down": "§eIris išjungiamas ir nebepriima paketų atsisiuntimų.",
"iris.runtime.pack_download.downloading": "Atsiunčiama {url}", "iris.runtime.pack_download.downloading": "Atsiunčiama {url}",
"iris.runtime.pack_download.failed_to_find": "Nepavyko rasti pakuotės {url}", "iris.runtime.pack_download.failed_to_find": "Nepavyko rasti pakuotės {url}",
"iris.runtime.pack_download.unpacking": "Išpakavimas {repository}", "iris.runtime.pack_download.unpacking": "Išpakavimas {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "Kita pakuotė naudoja raktą {key}. Importuoti nepavyko!", "iris.runtime.pack_download.pack_key_conflict": "Kita pakuotė naudoja raktą {key}. Importuoti nepavyko!",
"iris.runtime.pack_download.acquired": "Sėkmingai įgyta {name}.", "iris.runtime.pack_download.acquired": "Sėkmingai įgyta {name}.",
"iris.runtime.pack_download.already_installed": "Paketas {key} jau įdiegtas, atsisiuntimas praleidžiamas.", "iris.runtime.pack_download.already_installed": "Paketas {key} jau įdiegtas, atsisiuntimas praleidžiamas.",
"iris.runtime.pack_download.in_progress": "Jau vyksta kitas „Iris“ paketo atsisiuntimas. Palaukite, kol jis bus baigtas, ir bandykite dar kartą.",
"iris.runtime.pack_download.validation_failed": "Pakuotė \"{pack}\"nepavyko patvirtinimas; pasaulio ir Studio kūrimas bus atsisakyta. Motyvai:", "iris.runtime.pack_download.validation_failed": "Pakuotė \"{pack}\"nepavyko patvirtinimas; pasaulio ir Studio kūrimas bus atsisakyta. Motyvai:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash gestart: {chunks} chunks rond 0,0 in buffers (onaangeroerde wereld), draden={threads} modus={mode}", "iris.runtime.golden.started": "GoldenHash gestart: {chunks} chunks rond 0,0 in buffers (onaangeroerde wereld), draden={threads} modus={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] chunk {x},{z} gehashed", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] chunk {x},{z} gehashed",
"iris.runtime.golden.chunk_failed": "Chunk. {x},{z} mislukt: {type}", "iris.runtime.golden.chunk_failed": "Chunk. {x},{z} mislukt: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6PACK DOWNLOADEN§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aIris-pack '{pack}' geïnstalleerd§8 | §f{transferred}§7 in §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eIris-pack '{pack}' is al geïnstalleerd.",
"iris.runtime.pack_download.progress.failed": "§cDownload van het Iris-pack mislukt.§7 Bekijk de downloadgegevens hierboven en probeer het opnieuw.",
"iris.runtime.pack_download.progress.failed_detail": "§cDownload van het Iris-pack mislukt.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eDownload van het Iris-pack geannuleerd vóór publicatie.",
"iris.runtime.pack_download.progress.restart": "§6Herstart vereist§8 | §7Herstart de server voordat je met dit pack een wereld maakt of vervangt.",
"iris.runtime.pack_download.progress.phase.connecting": "Verbinden",
"iris.runtime.pack_download.progress.phase.downloading": "Downloaden",
"iris.runtime.pack_download.progress.phase.unpacking": "Uitpakken",
"iris.runtime.pack_download.progress.phase.validating": "Valideren",
"iris.runtime.pack_download.progress.phase.publishing": "Publiceren",
"iris.runtime.pack_download.progress.source.remote": "Extern ZIP-bestand",
"iris.runtime.pack_download.invalid_source": "§cKies precies één bron: /iris download pack=overworld, /iris download pack=underworld of /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cIris vereist een geldige HTTP- of HTTPS-URL naar een .zip-bestand.",
"iris.runtime.pack_download.invalid_built_in": "§cIris biedt alleen ingebouwde downloads voor 'overworld' en 'underworld'.",
"iris.runtime.pack_download.shutting_down": "§eIris wordt afgesloten en accepteert geen pakketdownloads.",
"iris.runtime.pack_download.downloading": "Downloaden {url}", "iris.runtime.pack_download.downloading": "Downloaden {url}",
"iris.runtime.pack_download.failed_to_find": "Kon pakket niet vinden op {url}", "iris.runtime.pack_download.failed_to_find": "Kon pakket niet vinden op {url}",
"iris.runtime.pack_download.unpacking": "Uitpakken {repository}", "iris.runtime.pack_download.unpacking": "Uitpakken {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "Een ander pakje gebruikt de sleutel {key}. Importeren mislukt!", "iris.runtime.pack_download.pack_key_conflict": "Een ander pakje gebruikt de sleutel {key}. Importeren mislukt!",
"iris.runtime.pack_download.acquired": "Succesvol verworven {name}.", "iris.runtime.pack_download.acquired": "Succesvol verworven {name}.",
"iris.runtime.pack_download.already_installed": "Pack {key} is al geïnstalleerd, download wordt overgeslagen.", "iris.runtime.pack_download.already_installed": "Pack {key} is al geïnstalleerd, download wordt overgeslagen.",
"iris.runtime.pack_download.in_progress": "Er wordt al een ander Iris-pack gedownload. Wacht tot dit is voltooid voordat je het opnieuw probeert.",
"iris.runtime.pack_download.validation_failed": "Verpakking{pack}' mislukte validatie; wereld en Studio creatie zal worden geweigerd. Motivering:", "iris.runtime.pack_download.validation_failed": "Verpakking{pack}' mislukte validatie; wereld en Studio creatie zal worden geweigerd. Motivering:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash rozpoczęte: {chunks} części wokół 0,0 w zderzakach (świat nietknięty), wątki ={threads} tryb ={mode}", "iris.runtime.golden.started": "GoldenHash rozpoczęte: {chunks} części wokół 0,0 w zderzakach (świat nietknięty), wątki ={threads} tryb ={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] cząstka {x},{z} łuszczone", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] cząstka {x},{z} łuszczone",
"iris.runtime.golden.chunk_failed": "Chunk Przewodniczący {x},{z} niepowodzenie: {type}", "iris.runtime.golden.chunk_failed": "Chunk Przewodniczący {x},{z} niepowodzenie: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6POBIERANIE PAKIETU§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aPakiet Iris '{pack}' zainstalowany§8 | §f{transferred}§7 w §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§ePakiet Iris '{pack}' jest już zainstalowany.",
"iris.runtime.pack_download.progress.failed": "§cPobieranie pakietu Iris nie powiodło się.§7 Sprawdź powyższe szczegóły pobierania i spróbuj ponownie.",
"iris.runtime.pack_download.progress.failed_detail": "§cPobieranie pakietu Iris nie powiodło się.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§ePobieranie pakietu Iris anulowano przed publikacją.",
"iris.runtime.pack_download.progress.restart": "§6Wymagane ponowne uruchomienie§8 | §7Uruchom serwer ponownie przed utworzeniem lub zastąpieniem świata tym pakietem.",
"iris.runtime.pack_download.progress.phase.connecting": "Łączenie",
"iris.runtime.pack_download.progress.phase.downloading": "Pobieranie",
"iris.runtime.pack_download.progress.phase.unpacking": "Rozpakowywanie",
"iris.runtime.pack_download.progress.phase.validating": "Sprawdzanie",
"iris.runtime.pack_download.progress.phase.publishing": "Publikowanie",
"iris.runtime.pack_download.progress.source.remote": "Zdalny plik ZIP",
"iris.runtime.pack_download.invalid_source": "§cWybierz dokładnie jedno źródło: /iris download pack=overworld, /iris download pack=underworld lub /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cIris wymaga prawidłowego adresu URL HTTP lub HTTPS do pliku .zip.",
"iris.runtime.pack_download.invalid_built_in": "§cIris udostępnia wbudowane pobieranie tylko dla 'overworld' i 'underworld'.",
"iris.runtime.pack_download.shutting_down": "§eTrwa wyłączanie Iris; pobieranie pakietów nie jest już przyjmowane.",
"iris.runtime.pack_download.downloading": "Pobieranie {url}", "iris.runtime.pack_download.downloading": "Pobieranie {url}",
"iris.runtime.pack_download.failed_to_find": "Nie udało się znaleźć pakietu w {url}", "iris.runtime.pack_download.failed_to_find": "Nie udało się znaleźć pakietu w {url}",
"iris.runtime.pack_download.unpacking": "Rozpakowanie {repository}", "iris.runtime.pack_download.unpacking": "Rozpakowanie {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "Inny pakiet używa klucza {key}. Import nie powiódł się!", "iris.runtime.pack_download.pack_key_conflict": "Inny pakiet używa klucza {key}. Import nie powiódł się!",
"iris.runtime.pack_download.acquired": "Udane nabycie {name}.", "iris.runtime.pack_download.acquired": "Udane nabycie {name}.",
"iris.runtime.pack_download.already_installed": "Pakiet {key} jest już zainstalowany, pomijanie pobierania.", "iris.runtime.pack_download.already_installed": "Pakiet {key} jest już zainstalowany, pomijanie pobierania.",
"iris.runtime.pack_download.in_progress": "Trwa już pobieranie innego pakietu Iris. Poczekaj na jego zakończenie, zanim spróbujesz ponownie.",
"iris.runtime.pack_download.validation_failed": "Paczka \"{pack}\"nieudaną walidację; świat i tworzenie studia zostaną odrzucone. Uzasadnienie:", "iris.runtime.pack_download.validation_failed": "Paczka \"{pack}\"nieudaną walidację; świat i tworzenie studia zostaną odrzucone. Uzasadnienie:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash iniciado: {chunks} chunks ao redor 0,0 em buffers (mundo intocado), threads={threads} modo={mode}", "iris.runtime.golden.started": "GoldenHash iniciado: {chunks} chunks ao redor 0,0 em buffers (mundo intocado), threads={threads} modo={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] chunk {x},{z} hashid", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] chunk {x},{z} hashid",
"iris.runtime.golden.chunk_failed": "Chunk. {x},{z} falhou: {type}", "iris.runtime.golden.chunk_failed": "Chunk. {x},{z} falhou: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6TRANSFERÊNCIA DO PACK§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aPack Iris '{pack}' instalado§8 | §f{transferred}§7 em §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eO pack Iris '{pack}' já está instalado.",
"iris.runtime.pack_download.progress.failed": "§cA transferência do pack Iris falhou.§7 Reveja os detalhes da transferência acima e tente novamente.",
"iris.runtime.pack_download.progress.failed_detail": "§cA transferência do pack Iris falhou.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eA transferência do pack Iris foi cancelada antes da publicação.",
"iris.runtime.pack_download.progress.restart": "§6Reinício necessário§8 | §7Reinicie o servidor antes de criar ou substituir um mundo com este pack.",
"iris.runtime.pack_download.progress.phase.connecting": "A ligar",
"iris.runtime.pack_download.progress.phase.downloading": "A transferir",
"iris.runtime.pack_download.progress.phase.unpacking": "A descompactar",
"iris.runtime.pack_download.progress.phase.validating": "A validar",
"iris.runtime.pack_download.progress.phase.publishing": "A publicar",
"iris.runtime.pack_download.progress.source.remote": "ZIP remoto",
"iris.runtime.pack_download.invalid_source": "§cEscolha exatamente uma origem: /iris download pack=overworld, /iris download pack=underworld ou /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cIris requer um URL HTTP ou HTTPS válido para um ficheiro .zip.",
"iris.runtime.pack_download.invalid_built_in": "§cIris apenas disponibiliza transferências integradas para 'overworld' e 'underworld'.",
"iris.runtime.pack_download.shutting_down": "§eIris está a encerrar e não aceita transferências de packs.",
"iris.runtime.pack_download.downloading": "Baixando {url}", "iris.runtime.pack_download.downloading": "Baixando {url}",
"iris.runtime.pack_download.failed_to_find": "Não foi possível encontrar o pacote em {url}", "iris.runtime.pack_download.failed_to_find": "Não foi possível encontrar o pacote em {url}",
"iris.runtime.pack_download.unpacking": "Desembalar {repository}", "iris.runtime.pack_download.unpacking": "Desembalar {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "Outro pacote está usando a chave {key}. A importação falhou!", "iris.runtime.pack_download.pack_key_conflict": "Outro pacote está usando a chave {key}. A importação falhou!",
"iris.runtime.pack_download.acquired": "Adquirido com sucesso {name}.", "iris.runtime.pack_download.acquired": "Adquirido com sucesso {name}.",
"iris.runtime.pack_download.already_installed": "O pack {key} já está instalado, download ignorado.", "iris.runtime.pack_download.already_installed": "O pack {key} já está instalado, download ignorado.",
"iris.runtime.pack_download.in_progress": "Já está em curso a transferência de outro pack Iris. Aguarde que termine antes de tentar novamente.",
"iris.runtime.pack_download.validation_failed": "Embalar '{pack}' validação falhada; a criação de mundo e estúdio será recusada. Motivos:", "iris.runtime.pack_download.validation_failed": "Embalar '{pack}' validação falhada; a criação de mundo e estúdio será recusada. Motivos:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash Начало: {chunks} чанки вокруг 0,0 в буферах (нетронутый мир), нити{threads} режим{mode}", "iris.runtime.golden.started": "GoldenHash Начало: {chunks} чанки вокруг 0,0 в буферах (нетронутый мир), нити{threads} режим{mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] чанк {x},{z} хешированный", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] чанк {x},{z} хешированный",
"iris.runtime.golden.chunk_failed": "Кусок {x},{z} не удалось: {type}", "iris.runtime.golden.chunk_failed": "Кусок {x},{z} не удалось: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6ЗАГРУЗКА ПАКА§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aПак Iris «{pack}» установлен§8 | §f{transferred}§7 за §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eПак Iris «{pack}» уже установлен.",
"iris.runtime.pack_download.progress.failed": "§cНе удалось загрузить пак Iris.§7 Проверьте сведения о загрузке выше и повторите попытку.",
"iris.runtime.pack_download.progress.failed_detail": "§cНе удалось загрузить пак Iris.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eЗагрузка пака Iris отменена до публикации.",
"iris.runtime.pack_download.progress.restart": "§6Требуется перезапуск§8 | §7Перезапустите сервер, прежде чем создавать или заменять мир с помощью этого пака.",
"iris.runtime.pack_download.progress.phase.connecting": "Подключение",
"iris.runtime.pack_download.progress.phase.downloading": "Загрузка",
"iris.runtime.pack_download.progress.phase.unpacking": "Распаковка",
"iris.runtime.pack_download.progress.phase.validating": "Проверка",
"iris.runtime.pack_download.progress.phase.publishing": "Публикация",
"iris.runtime.pack_download.progress.source.remote": "Удалённый ZIP-архив",
"iris.runtime.pack_download.invalid_source": "§cВыберите ровно один источник: /iris download pack=overworld, /iris download pack=underworld или /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cДля Iris требуется допустимый HTTP- или HTTPS-адрес файла .zip.",
"iris.runtime.pack_download.invalid_built_in": "§cВстроенная загрузка Iris доступна только для 'overworld' и 'underworld'.",
"iris.runtime.pack_download.shutting_down": "§eIris завершает работу и не принимает загрузки пакетов.",
"iris.runtime.pack_download.downloading": "Скачать {url}", "iris.runtime.pack_download.downloading": "Скачать {url}",
"iris.runtime.pack_download.failed_to_find": "Не удалось найти стаю {url}", "iris.runtime.pack_download.failed_to_find": "Не удалось найти стаю {url}",
"iris.runtime.pack_download.unpacking": "распаковка {repository}", "iris.runtime.pack_download.unpacking": "распаковка {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "Другой пакет использует ключ. {key}. Импорт провалился!", "iris.runtime.pack_download.pack_key_conflict": "Другой пакет использует ключ. {key}. Импорт провалился!",
"iris.runtime.pack_download.acquired": "Успешно приобретенный {name}.", "iris.runtime.pack_download.acquired": "Успешно приобретенный {name}.",
"iris.runtime.pack_download.already_installed": "Пак {key} уже установлен, загрузка пропущена.", "iris.runtime.pack_download.already_installed": "Пак {key} уже установлен, загрузка пропущена.",
"iris.runtime.pack_download.in_progress": "Уже выполняется загрузка другого пака Iris. Дождитесь её завершения, прежде чем повторить попытку.",
"iris.runtime.pack_download.validation_failed": "Пакуй.{pack}Неудачная проверка; мир и создание студии будут отклонены. Причины:", "iris.runtime.pack_download.validation_failed": "Пакуй.{pack}Неудачная проверка; мир и создание студии будут отклонены. Причины:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash başladı: {chunks} Etrafında 0,0 Buffers'te (dünyayı terk etti), iplikler ={threads} mod ={mode}", "iris.runtime.golden.started": "GoldenHash başladı: {chunks} Etrafında 0,0 Buffers'te (dünyayı terk etti), iplikler ={threads} mod ={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] chunk {x},{z} Havehed", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] chunk {x},{z} Havehed",
"iris.runtime.golden.chunk_failed": "Chunk. {x},{z} başarısız: {type}", "iris.runtime.golden.chunk_failed": "Chunk. {x},{z} başarısız: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6PAKET İNDİRME§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aIris paketi '{pack}' kuruldu§8 | §f{transferred}§7, süre: §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eIris paketi '{pack}' zaten kurulu.",
"iris.runtime.pack_download.progress.failed": "§cIris paketi indirilemedi.§7 Yukarıdaki indirme ayrıntılarını inceleyip yeniden deneyin.",
"iris.runtime.pack_download.progress.failed_detail": "§cIris paketi indirilemedi.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eIris paketi indirme işlemi yayınlanmadan önce iptal edildi.",
"iris.runtime.pack_download.progress.restart": "§6Yeniden başlatma gerekli§8 | §7Bu paketle bir dünya oluşturmadan veya değiştirmeden önce sunucuyu yeniden başlatın.",
"iris.runtime.pack_download.progress.phase.connecting": "Bağlanıyor",
"iris.runtime.pack_download.progress.phase.downloading": "İndiriliyor",
"iris.runtime.pack_download.progress.phase.unpacking": "Arşivden çıkarılıyor",
"iris.runtime.pack_download.progress.phase.validating": "Doğrulanıyor",
"iris.runtime.pack_download.progress.phase.publishing": "Yayınlanıyor",
"iris.runtime.pack_download.progress.source.remote": "Uzak ZIP dosyası",
"iris.runtime.pack_download.invalid_source": "§cTam olarak bir kaynak seçin: /iris download pack=overworld, /iris download pack=underworld veya /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cIris, .zip dosyasına ait geçerli bir HTTP veya HTTPS URL'si gerektirir.",
"iris.runtime.pack_download.invalid_built_in": "§cIris yalnızca 'overworld' ve 'underworld' için yerleşik indirmeler sunar.",
"iris.runtime.pack_download.shutting_down": "§eIris kapanıyor ve paket indirmelerini kabul etmiyor.",
"iris.runtime.pack_download.downloading": "Downloading indir {url}", "iris.runtime.pack_download.downloading": "Downloading indir {url}",
"iris.runtime.pack_download.failed_to_find": "Paket bulmak için başarısız oldu {url}", "iris.runtime.pack_download.failed_to_find": "Paket bulmak için başarısız oldu {url}",
"iris.runtime.pack_download.unpacking": "Unpackinging {repository}", "iris.runtime.pack_download.unpacking": "Unpackinging {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "Başka bir paket anahtarı kullanıyor {key}. İthalat başarısız oldu!", "iris.runtime.pack_download.pack_key_conflict": "Başka bir paket anahtarı kullanıyor {key}. İthalat başarısız oldu!",
"iris.runtime.pack_download.acquired": "Başarılı bir şekilde satın alındı {name}.", "iris.runtime.pack_download.acquired": "Başarılı bir şekilde satın alındı {name}.",
"iris.runtime.pack_download.already_installed": "{key} paketi zaten kurulu, indirme atlanıyor.", "iris.runtime.pack_download.already_installed": "{key} paketi zaten kurulu, indirme atlanıyor.",
"iris.runtime.pack_download.in_progress": "Başka bir Iris paketi indirme işlemi zaten devam ediyor. Yeniden denemeden önce tamamlanmasını bekleyin.",
"iris.runtime.pack_download.validation_failed": "Pack \"{pack}“Başarısız doğrulama; dünya ve Stüdyo yaratımı reddedilecektir. Sebepler:", "iris.runtime.pack_download.validation_failed": "Pack \"{pack}“Başarısız doğrulama; dünya ve Stüdyo yaratımı reddedilecektir. Sebepler:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash bắt đầu: {chunks} Cuộn quanh 0,0 trong bộ đệm (thế giới chưa động đến), các sợi={threads} Chế độ ={mode}", "iris.runtime.golden.started": "GoldenHash bắt đầu: {chunks} Cuộn quanh 0,0 trong bộ đệm (thế giới chưa động đến), các sợi={threads} Chế độ ={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] Chunk {x},{z} bị đóng cửa", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] Chunk {x},{z} bị đóng cửa",
"iris.runtime.golden.chunk_failed": "Chunk. {x},{z} thất bại: {type}", "iris.runtime.golden.chunk_failed": "Chunk. {x},{z} thất bại: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6TẢI GÓI§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aĐã cài đặt gói Iris '{pack}'§8 | §f{transferred}§7 trong §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eGói Iris '{pack}' đã được cài đặt.",
"iris.runtime.pack_download.progress.failed": "§cKhông thể tải gói Iris.§7 Hãy xem lại thông tin tải xuống ở trên rồi thử lại.",
"iris.runtime.pack_download.progress.failed_detail": "§cKhông thể tải gói Iris.§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eĐã hủy tải gói Iris trước khi xuất bản.",
"iris.runtime.pack_download.progress.restart": "§6Cần khởi động lại§8 | §7Hãy khởi động lại máy chủ trước khi tạo hoặc thay thế một thế giới bằng gói này.",
"iris.runtime.pack_download.progress.phase.connecting": "Đang kết nối",
"iris.runtime.pack_download.progress.phase.downloading": "Đang tải xuống",
"iris.runtime.pack_download.progress.phase.unpacking": "Đang giải nén",
"iris.runtime.pack_download.progress.phase.validating": "Đang xác thực",
"iris.runtime.pack_download.progress.phase.publishing": "Đang xuất bản",
"iris.runtime.pack_download.progress.source.remote": "Tệp ZIP từ xa",
"iris.runtime.pack_download.invalid_source": "§cChỉ chọn một nguồn: /iris download pack=overworld, /iris download pack=underworld hoặc /iris download link=zip-url.",
"iris.runtime.pack_download.invalid_url": "§cIris yêu cầu URL HTTP hoặc HTTPS hợp lệ trỏ đến tệp .zip.",
"iris.runtime.pack_download.invalid_built_in": "§cIris chỉ cung cấp bản tải xuống tích hợp cho 'overworld' và 'underworld'.",
"iris.runtime.pack_download.shutting_down": "§eIris đang tắt và không nhận yêu cầu tải gói.",
"iris.runtime.pack_download.downloading": "Đang tải về {url}", "iris.runtime.pack_download.downloading": "Đang tải về {url}",
"iris.runtime.pack_download.failed_to_find": "Không tìm thấy gói tại {url}", "iris.runtime.pack_download.failed_to_find": "Không tìm thấy gói tại {url}",
"iris.runtime.pack_download.unpacking": "Đang mở gói {repository}", "iris.runtime.pack_download.unpacking": "Đang mở gói {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "Name {key}. Nhập thất bại!", "iris.runtime.pack_download.pack_key_conflict": "Name {key}. Nhập thất bại!",
"iris.runtime.pack_download.acquired": "Được thành công {name}.", "iris.runtime.pack_download.acquired": "Được thành công {name}.",
"iris.runtime.pack_download.already_installed": "Gói {key} đã được cài đặt, bỏ qua tải xuống.", "iris.runtime.pack_download.already_installed": "Gói {key} đã được cài đặt, bỏ qua tải xuống.",
"iris.runtime.pack_download.in_progress": "Một gói Iris khác đang được tải xuống. Hãy chờ quá trình này hoàn tất trước khi thử lại.",
"iris.runtime.pack_download.validation_failed": "Gói '{pack}'Đã thất bại trong việc xác nhận; thế giới và phòng thu sẽ bị từ chối. Lý do:", "iris.runtime.pack_download.validation_failed": "Gói '{pack}'Đã thất bại trong việc xác nhận; thế giới và phòng thu sẽ bị từ chối. Lý do:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash 开始 : {chunks} 块绕 0,0 在缓冲器(世界未触动)中,线程={threads} 模式={mode}", "iris.runtime.golden.started": "GoldenHash 开始 : {chunks} 块绕 0,0 在缓冲器(世界未触动)中,线程={threads} 模式={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] 块 {x},{z} 散开", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] 块 {x},{z} 散开",
"iris.runtime.golden.chunk_failed": "块 {x},{z} 失败: {type}", "iris.runtime.golden.chunk_failed": "块 {x},{z} 失败: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6下载包§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aIris 包“{pack}”已安装§8 | §f{transferred}§7,用时 §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eIris 包“{pack}”已安装。",
"iris.runtime.pack_download.progress.failed": "§cIris 包下载失败。§7 请查看上方的下载详情后重试。",
"iris.runtime.pack_download.progress.failed_detail": "§cIris 包下载失败。§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eIris 包下载已在发布前取消。",
"iris.runtime.pack_download.progress.restart": "§6需要重启§8 | §7使用此包创建或替换世界前,请重启服务器。",
"iris.runtime.pack_download.progress.phase.connecting": "正在连接",
"iris.runtime.pack_download.progress.phase.downloading": "正在下载",
"iris.runtime.pack_download.progress.phase.unpacking": "正在解压",
"iris.runtime.pack_download.progress.phase.validating": "正在验证",
"iris.runtime.pack_download.progress.phase.publishing": "正在发布",
"iris.runtime.pack_download.progress.source.remote": "远程 ZIP",
"iris.runtime.pack_download.invalid_source": "§c请仅选择一个来源:/iris download pack=overworld、/iris download pack=underworld 或 /iris download link=zip-url›。",
"iris.runtime.pack_download.invalid_url": "§cIris 需要指向 .zip 文件的有效 HTTP 或 HTTPS URL。",
"iris.runtime.pack_download.invalid_built_in": "§cIris 仅为 'overworld' 和 'underworld' 提供内置下载。",
"iris.runtime.pack_download.shutting_down": "§eIris 正在关闭,不再接受资源包下载。",
"iris.runtime.pack_download.downloading": "下载 {url}", "iris.runtime.pack_download.downloading": "下载 {url}",
"iris.runtime.pack_download.failed_to_find": "找到包失败 {url}", "iris.runtime.pack_download.failed_to_find": "找到包失败 {url}",
"iris.runtime.pack_download.unpacking": "正在解压 {repository}", "iris.runtime.pack_download.unpacking": "正在解压 {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "另一个包是用钥匙 {key}. 导入失败 !", "iris.runtime.pack_download.pack_key_conflict": "另一个包是用钥匙 {key}. 导入失败 !",
"iris.runtime.pack_download.acquired": "已成功获取 {name}.", "iris.runtime.pack_download.acquired": "已成功获取 {name}.",
"iris.runtime.pack_download.already_installed": "包 {key} 已安装,跳过下载。", "iris.runtime.pack_download.already_installed": "包 {key} 已安装,跳过下载。",
"iris.runtime.pack_download.in_progress": "另一个 Iris 包下载已在进行中。请等待其完成后再试。",
"iris.runtime.pack_download.validation_failed": "包{pack}' 验证失败; 世界和工作室的创建将被拒绝. 原因:", "iris.runtime.pack_download.validation_failed": "包{pack}' 验证失败; 世界和工作室的创建将被拒绝. 原因:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -1254,6 +1254,27 @@
"iris.runtime.golden.started": "GoldenHash 開始 : {chunks} 塊繞 0,0 在緩衝器(世界未觸動)中,執行緒={threads} 模式={mode}", "iris.runtime.golden.started": "GoldenHash 開始 : {chunks} 塊繞 0,0 在緩衝器(世界未觸動)中,執行緒={threads} 模式={mode}",
"iris.runtime.golden.chunk_hashed": "[{done}/{total}] 塊 {x},{z} 散開", "iris.runtime.golden.chunk_hashed": "[{done}/{total}] 塊 {x},{z} 散開",
"iris.runtime.golden.chunk_failed": "塊 {x},{z} 失敗: {type}", "iris.runtime.golden.chunk_failed": "塊 {x},{z} 失敗: {type}",
"iris.runtime.pack_download.progress.start": "§aIris §6下載套件§8 | §f{source}",
"iris.runtime.pack_download.progress.phase": "§aIris §b{phase}§8 | §7{source}",
"iris.runtime.pack_download.progress.determinate": "{bar}§7 §e{percent}%§8 | §f{transferred}§7/§f{total}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.indeterminate": "{bar}§7 §b{phase}§8 | §f{transferred}§8 | §b{rate}/s",
"iris.runtime.pack_download.progress.detail": "§8 - §7{detail}",
"iris.runtime.pack_download.progress.complete": "§aIris 套件「{pack}」已安裝§8 | §f{transferred}§7,用時 §f{elapsed}",
"iris.runtime.pack_download.progress.unchanged": "§eIris 套件「{pack}」已安裝。",
"iris.runtime.pack_download.progress.failed": "§cIris 套件下載失敗。§7 請檢視上方的下載詳細資訊後重試。",
"iris.runtime.pack_download.progress.failed_detail": "§cIris 套件下載失敗。§7 {error}",
"iris.runtime.pack_download.progress.cancelled": "§eIris 套件下載已在發佈前取消。",
"iris.runtime.pack_download.progress.restart": "§6需要重新啟動§8 | §7使用此套件建立或替換世界前,請重新啟動伺服器。",
"iris.runtime.pack_download.progress.phase.connecting": "正在連線",
"iris.runtime.pack_download.progress.phase.downloading": "正在下載",
"iris.runtime.pack_download.progress.phase.unpacking": "正在解壓縮",
"iris.runtime.pack_download.progress.phase.validating": "正在驗證",
"iris.runtime.pack_download.progress.phase.publishing": "正在發佈",
"iris.runtime.pack_download.progress.source.remote": "遠端 ZIP",
"iris.runtime.pack_download.invalid_source": "§c請只選擇一個來源:/iris download pack=overworld、/iris download pack=underworld 或 /iris download link=zip-url›。",
"iris.runtime.pack_download.invalid_url": "§cIris 需要指向 .zip 檔案的有效 HTTP 或 HTTPS URL。",
"iris.runtime.pack_download.invalid_built_in": "§cIris 僅為 'overworld' 和 'underworld' 提供內建下載。",
"iris.runtime.pack_download.shutting_down": "§eIris 正在關閉,不再接受資源包下載。",
"iris.runtime.pack_download.downloading": "下載 {url}", "iris.runtime.pack_download.downloading": "下載 {url}",
"iris.runtime.pack_download.failed_to_find": "找到包失敗 {url}", "iris.runtime.pack_download.failed_to_find": "找到包失敗 {url}",
"iris.runtime.pack_download.unpacking": "正在解壓縮 {repository}", "iris.runtime.pack_download.unpacking": "正在解壓縮 {repository}",
@@ -1276,6 +1297,7 @@
"iris.runtime.pack_download.pack_key_conflict": "另一個包是用鑰匙 {key}. 匯入失敗 !", "iris.runtime.pack_download.pack_key_conflict": "另一個包是用鑰匙 {key}. 匯入失敗 !",
"iris.runtime.pack_download.acquired": "已成功獲取 {name}.", "iris.runtime.pack_download.acquired": "已成功獲取 {name}.",
"iris.runtime.pack_download.already_installed": "套件 {key} 已安裝,跳過下載。", "iris.runtime.pack_download.already_installed": "套件 {key} 已安裝,跳過下載。",
"iris.runtime.pack_download.in_progress": "另一個 Iris 套件下載已在進行中。請等待其完成後再試。",
"iris.runtime.pack_download.validation_failed": "包{pack}' 驗證失敗; 世界和工作室的建立將被拒絕. 原因:", "iris.runtime.pack_download.validation_failed": "包{pack}' 驗證失敗; 世界和工作室的建立將被拒絕. 原因:",
"iris.runtime.pack_download.validation_reason": " - {reason}", "iris.runtime.pack_download.validation_reason": " - {reason}",
"iris.runtime.pack_download.validated_with_warnings": { "iris.runtime.pack_download.validated_with_warnings": {
@@ -58,6 +58,21 @@ public class WorldReplacementBootstrapTest {
assertEquals(Phase.PUBLISHED, loadSingle().phase()); assertEquals(Phase.PUBLISHED, loadSingle().phase());
} }
@Test
public void publishesArmedReplacementAfterFinderAddsNestedMetadata() throws Exception {
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
configureReplacement(transaction);
Path dimensions = paths(transaction).stage().resolve("iris/pack/dimensions");
Files.writeString(dimensions.resolve(".DS_Store"), "Finder metadata");
WorldReplacementBootstrap.ReconcileResult result = reconcile();
assertEquals(1, result.published());
assertEquals("replacement", replacementContent(target.worldDirectory()));
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
assertEquals(Phase.PUBLISHED, loadSingle().phase());
}
@Test @Test
public void publishesArmedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception { public void publishesArmedReplacementWhenOriginalConfigurationAlreadyMatchesReplacement() throws Exception {
configureExistingReplacement(); configureExistingReplacement();
@@ -59,6 +59,29 @@ public class WorldReplacementFilesystemTest {
assertFalse(Files.exists(paths.stage())); assertFalse(Files.exists(paths.stage()));
} }
@Test
public void publishesStagedWorldGenerationSettingsWithoutChangingTheRetainedBackup() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("publish-seed-override", TRANSACTION_ID);
writeOriginalTarget(paths, "original");
String fingerprint = writeStage(paths, "replacement");
Path stagedSettings = paths.stage().resolve("data/minecraft/world_gen_settings.dat");
Files.createDirectories(stagedSettings.getParent());
Files.writeString(stagedSettings, "replacement-generation");
WorldReplacementFilesystem.publish(paths, true, fingerprint);
assertEquals("replacement-generation", Files.readString(
paths.target().resolve("data/minecraft/world_gen_settings.dat")));
assertEquals("generation", Files.readString(
paths.backup().resolve("data/minecraft/world_gen_settings.dat")));
WorldReplacementFilesystem.rollback(paths, true);
assertEquals("generation", Files.readString(
paths.target().resolve("data/minecraft/world_gen_settings.dat")));
assertFalse(Files.exists(paths.backup()));
}
@Test @Test
public void rejectsAbsentTargetForReplacementAdmission() throws Exception { public void rejectsAbsentTargetForReplacementAdmission() throws Exception {
WorldReplacementFilesystem.ReplacementPaths paths = paths("admit-absent", TRANSACTION_ID); WorldReplacementFilesystem.ReplacementPaths paths = paths("admit-absent", TRANSACTION_ID);
@@ -372,6 +395,28 @@ public class WorldReplacementFilesystemTest {
assertNotEquals(expected, WorldReplacementFilesystem.fingerprintPack(pack)); assertNotEquals(expected, WorldReplacementFilesystem.fingerprintPack(pack));
} }
@Test
public void ignoresNestedFinderMetadataAddedAfterFingerprinting() throws Exception {
Path pack = temporaryFolder.newFolder("nested-finder-metadata").toPath();
Path objects = Files.createDirectories(pack.resolve("objects/oak"));
Files.writeString(objects.resolve("tree.iob"), "tree");
String expected = WorldReplacementFilesystem.fingerprintPack(pack);
Files.writeString(objects.resolve(".DS_Store"), "Finder metadata");
assertEquals(expected, WorldReplacementFilesystem.fingerprintPack(pack));
}
@Test
public void rejectsNestedFinderMetadataSymlink() throws Exception {
Path pack = temporaryFolder.newFolder("unsafe-nested-finder-metadata").toPath();
Path objects = Files.createDirectories(pack.resolve("objects/oak"));
Path outside = temporaryFolder.newFile("outside-finder-metadata.txt").toPath();
Files.createSymbolicLink(objects.resolve(".DS_Store"), outside);
assertThrows(IOException.class, () -> WorldReplacementFilesystem.fingerprintPack(pack));
}
@Test @Test
public void validatesExcludedAuthoringMetadataForUnsafeEntries() throws Exception { public void validatesExcludedAuthoringMetadataForUnsafeEntries() throws Exception {
Path pack = temporaryFolder.newFolder("unsafe-generated-metadata").toPath(); Path pack = temporaryFolder.newFolder("unsafe-generated-metadata").toPath();
@@ -10,6 +10,7 @@ import org.junit.rules.TemporaryFolder;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.OptionalLong;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
@@ -58,6 +59,104 @@ public class WorldReplacementSeedTest {
assertEquals("preserved", copiedData.getCompoundTag("dimensions").getString("marker")); assertEquals("preserved", copiedData.getCompoundTag("dimensions").getString("marker"));
} }
@Test
public void stagesTheRetainedAuthoritativeSeedWhenNoOverrideWasRequested() throws Exception {
CompoundTag data = new CompoundTag();
data.putLong("seed", SEED);
data.putString("generator", "preserved");
CompoundTag root = new CompoundTag();
root.put("data", data);
Path sourceWorld = writeSettings("inherited-source", root);
Path stagedWorld = temporaryFolder.newFolder("inherited-stage").toPath();
long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed(
sourceWorld,
stagedWorld,
OptionalLong.empty()
);
assertEquals(SEED, effectiveSeed);
assertEquals(SEED, WorldReplacementSeed.readAuthoritativeSeed(sourceWorld));
assertEquals(SEED, WorldReplacementSeed.readAuthoritativeSeed(stagedWorld));
assertTrue(Files.notExists(stagedWorld.resolve("data/paper")));
}
@Test
public void stagesAnExplicitSeedWithoutChangingTheRetainedWorld() throws Exception {
CompoundTag data = new CompoundTag();
data.putLong("seed", 1337L);
data.putString("generator", "preserved");
CompoundTag root = new CompoundTag();
root.put("data", data);
Path sourceWorld = writeSettings("override-source", root);
Path stagedWorld = temporaryFolder.newFolder("override-stage").toPath();
long effectiveSeed = WorldReplacementSeed.stageAuthoritativeSeed(
sourceWorld,
stagedWorld,
OptionalLong.of(SEED)
);
assertEquals(SEED, effectiveSeed);
assertEquals(1337L, WorldReplacementSeed.readAuthoritativeSeed(sourceWorld));
assertEquals(SEED, WorldReplacementSeed.readAuthoritativeSeed(stagedWorld));
NamedTag staged = NBTUtil.read(settingsPath(stagedWorld).toFile());
CompoundTag stagedRoot = (CompoundTag) staged.getTag();
assertEquals("preserved", stagedRoot.getCompoundTag("data").getString("generator"));
assertTrue(Files.notExists(stagedWorld.resolve("data/paper")));
}
@Test
public void rejectsAnExistingStagedSettingsFileWithoutChangingIt() throws Exception {
CompoundTag sourceData = new CompoundTag();
sourceData.putLong("seed", 1337L);
CompoundTag sourceRoot = new CompoundTag();
sourceRoot.put("data", sourceData);
Path sourceWorld = writeSettings("existing-source", sourceRoot);
CompoundTag stagedData = new CompoundTag();
stagedData.putLong("seed", 42L);
CompoundTag stagedRoot = new CompoundTag();
stagedRoot.put("data", stagedData);
Path stagedWorld = writeSettings("existing-stage", stagedRoot);
IOException failure = assertThrows(
IOException.class,
() -> WorldReplacementSeed.stageAuthoritativeSeed(
sourceWorld,
stagedWorld,
OptionalLong.of(SEED)
)
);
assertTrue(failure.getMessage().contains("already exist"));
assertEquals(42L, WorldReplacementSeed.readAuthoritativeSeed(stagedWorld));
assertEquals(1337L, WorldReplacementSeed.readAuthoritativeSeed(sourceWorld));
}
@Test
public void invalidSourceDoesNotCreatePartialStageState() throws Exception {
CompoundTag sourceRoot = new CompoundTag();
sourceRoot.put("data", new CompoundTag());
Path sourceWorld = writeSettings("invalid-stage-source", sourceRoot);
Path stagedWorld = temporaryFolder.newFolder("invalid-stage-target").toPath();
Path retainedMarker = stagedWorld.resolve("iris/pack/marker.txt");
Files.createDirectories(retainedMarker.getParent());
Files.writeString(retainedMarker, "retained");
assertThrows(
IOException.class,
() -> WorldReplacementSeed.stageAuthoritativeSeed(
sourceWorld,
stagedWorld,
OptionalLong.of(SEED)
)
);
assertTrue(Files.notExists(stagedWorld.resolve("data")));
assertEquals("retained", Files.readString(retainedMarker));
}
@Test @Test
public void rejectsMissingDataCompound() throws Exception { public void rejectsMissingDataCompound() throws Exception {
Path worldDirectory = writeSettings("missing-data", new CompoundTag()); Path worldDirectory = writeSettings("missing-data", new CompoundTag());
@@ -0,0 +1,126 @@
package art.arcane.iris.core.pack;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import org.junit.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
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.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
public class PackDownloadExecutionTest {
@Test
public void cancellationBeforeBindingCancelsLateSubmissionAndReleasesLeaseOnce() throws Exception {
LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class);
Future<?> future = mock(Future.class);
AtomicBoolean ran = new AtomicBoolean();
PackDownloadExecution execution = new PackDownloadExecution(
lease,
cancellation -> ran.set(true)
);
execution.cancel();
execution.bind(future);
execution.run();
assertTrue(execution.await(1L, TimeUnit.SECONDS));
assertFalse(ran.get());
verify(future).cancel(false);
verify(lease, times(1)).close();
}
@Test
public void cancellationOfBoundQueuedWorkReleasesLeaseWithoutRunning() throws Exception {
LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class);
Future<?> future = mock(Future.class);
AtomicBoolean ran = new AtomicBoolean();
PackDownloadExecution execution = new PackDownloadExecution(
lease,
cancellation -> ran.set(true)
);
execution.bind(future);
execution.cancel();
execution.run();
assertTrue(execution.await(1L, TimeUnit.SECONDS));
assertFalse(ran.get());
verify(future).cancel(false);
verify(lease, times(1)).close();
}
@Test
public void cancellationInterruptsRunningWorkOutsidePublication() throws Exception {
LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class);
CountDownLatch started = new CountDownLatch(1);
AtomicBoolean interrupted = new AtomicBoolean();
PackDownloadExecution execution = new PackDownloadExecution(lease, cancellation -> {
started.countDown();
try {
Thread.sleep(TimeUnit.SECONDS.toMillis(30L));
} catch (InterruptedException exception) {
interrupted.set(true);
Thread.currentThread().interrupt();
}
cancellation.checkpoint();
});
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
execution.bind(executor.submit(execution));
assertTrue(started.await(5L, TimeUnit.SECONDS));
execution.cancel();
assertTrue(execution.await(5L, TimeUnit.SECONDS));
assertTrue(interrupted.get());
verify(lease, times(1)).close();
} finally {
executor.shutdownNow();
assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS));
}
}
@Test
public void cancellationAllowsAtomicPublicationToCompleteWithoutInterruptingIt() throws Exception {
LifecycleOperationCoordinator.Lease lease = mock(LifecycleOperationCoordinator.Lease.class);
CountDownLatch publishing = new CountDownLatch(1);
CountDownLatch releasePublication = new CountDownLatch(1);
AtomicBoolean interrupted = new AtomicBoolean();
PackDownloadExecution execution = new PackDownloadExecution(lease, cancellation -> {
cancellation.beginPublication();
publishing.countDown();
try {
releasePublication.await();
} catch (InterruptedException exception) {
interrupted.set(true);
Thread.currentThread().interrupt();
}
});
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
execution.bind(executor.submit(execution));
assertTrue(publishing.await(5L, TimeUnit.SECONDS));
execution.cancel();
assertFalse(execution.await(100L, TimeUnit.MILLISECONDS));
assertFalse(interrupted.get());
releasePublication.countDown();
assertTrue(execution.await(5L, TimeUnit.SECONDS));
assertFalse(interrupted.get());
verify(lease, times(1)).close();
} finally {
releasePublication.countDown();
executor.shutdownNow();
assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS));
}
}
}
@@ -45,9 +45,11 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
@@ -219,13 +221,23 @@ public class PackDownloaderTest {
server.start(); server.start();
try { try {
File packsFolder = temp.newFolder("direct-url-packs"); File packsFolder = temp.newFolder("direct-url-packs");
String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/direct-pack.zip"; String url = "http://127.0.0.1:" + server.getAddress().getPort()
+ "/direct-pack.zip?token=secret&expires=soon";
List<PackDownloader.DownloadProgress> progress = new ArrayList<>();
List<String> feedback = new ArrayList<>();
AtomicInteger listenerCalls = new AtomicInteger();
PackDownloader.PackInstallResult result = PackDownloader.downloadUrl( PackDownloader.PackInstallResult result = PackDownloader.downloadUrl(
packsFolder, packsFolder,
url, url,
false, false,
ignored -> { feedback::add,
new PackDownloader.DownloadCancellation(),
update -> {
progress.add(update);
if (listenerCalls.getAndIncrement() == 0) {
throw new IllegalStateException("listener failure");
}
} }
); );
@@ -240,11 +252,161 @@ public class PackDownloaderTest {
assertTrue(Files.isRegularFile( assertTrue(Files.isRegularFile(
packsFolder.toPath().resolve("direct_pack/dimensions/direct_pack_supporting.json") packsFolder.toPath().resolve("direct_pack/dimensions/direct_pack_supporting.json")
)); ));
assertFalse(feedback.stream().anyMatch(line -> line.contains("secret") || line.contains("http://")));
assertDownloadProgress(progress, response.length);
} finally { } finally {
server.stop(0); server.stop(0);
} }
} }
@Test
public void activeDownloadRejectsSameAndDifferentUrlsWithoutQueueing() throws Exception {
File packsFolder = temp.newFolder("single-flight-packs");
byte[] slowArchive = packArchive("slow-download.zip", "slow_pack");
byte[] followupArchive = packArchive("followup-download.zip", "followup_pack");
AtomicInteger slowRequests = new AtomicInteger();
AtomicInteger followupRequests = new AtomicInteger();
CountDownLatch slowRequestStarted = new CountDownLatch(1);
CountDownLatch releaseSlowResponse = new CountDownLatch(1);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/slow.zip", exchange -> {
slowRequests.incrementAndGet();
slowRequestStarted.countDown();
try {
if (!releaseSlowResponse.await(10L, TimeUnit.SECONDS)) {
exchange.sendResponseHeaders(504, -1L);
return;
}
exchange.sendResponseHeaders(200, slowArchive.length);
exchange.getResponseBody().write(slowArchive);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
exchange.sendResponseHeaders(503, -1L);
} finally {
exchange.close();
}
});
server.createContext("/followup.zip", exchange -> {
followupRequests.incrementAndGet();
exchange.sendResponseHeaders(200, followupArchive.length);
exchange.getResponseBody().write(followupArchive);
exchange.close();
});
server.start();
ExecutorService executor = Executors.newFixedThreadPool(3);
try {
String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
String slowUrl = baseUrl + "/slow.zip";
String followupUrl = baseUrl + "/followup.zip";
Future<PackDownloader.PackInstallResult> active = executor.submit(() ->
PackDownloader.downloadUrl(packsFolder, slowUrl, false, ignored -> {
}));
assertTrue(slowRequestStarted.await(5L, TimeUnit.SECONDS));
Future<PackDownloader.PackInstallResult> sameUrl = executor.submit(() ->
PackDownloader.downloadUrl(packsFolder, slowUrl, false, ignored -> {
}));
Future<PackDownloader.PackInstallResult> differentUrl = executor.submit(() ->
PackDownloader.downloadUrl(packsFolder, followupUrl, false, ignored -> {
}));
assertBusy(sameUrl);
assertBusy(differentUrl);
assertEquals(1, slowRequests.get());
assertEquals(0, followupRequests.get());
releaseSlowResponse.countDown();
PackDownloader.PackInstallResult activeResult = active.get(15L, TimeUnit.SECONDS);
assertNotNull(activeResult);
assertEquals("slow_pack", activeResult.key());
PackDownloader.PackInstallResult followupResult = PackDownloader.downloadUrl(
packsFolder,
followupUrl,
false,
ignored -> {
}
);
assertNotNull(followupResult);
assertEquals("followup_pack", followupResult.key());
assertEquals(1, followupRequests.get());
} finally {
releaseSlowResponse.countDown();
executor.shutdownNow();
assertTrue(executor.awaitTermination(15L, TimeUnit.SECONDS));
server.stop(0);
}
}
@Test
public void cancellationInterruptsSlowDownloadAndReopensAdmission() throws Exception {
File packsFolder = temp.newFolder("cancelled-download-packs");
byte[] followupArchive = packArchive("cancel-followup.zip", "cancel_followup");
CountDownLatch slowRequestStarted = new CountDownLatch(1);
CountDownLatch releaseSlowResponse = new CountDownLatch(1);
AtomicInteger followupRequests = new AtomicInteger();
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/cancel-slow.zip", exchange -> {
slowRequestStarted.countDown();
try {
releaseSlowResponse.await(10L, TimeUnit.SECONDS);
exchange.sendResponseHeaders(504, -1L);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
} finally {
exchange.close();
}
});
server.createContext("/cancel-followup.zip", exchange -> {
followupRequests.incrementAndGet();
exchange.sendResponseHeaders(200, followupArchive.length);
exchange.getResponseBody().write(followupArchive);
exchange.close();
});
server.start();
ExecutorService executor = Executors.newSingleThreadExecutor();
PackDownloader.DownloadCancellation cancellation = new PackDownloader.DownloadCancellation();
try {
String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
Future<PackDownloader.PackInstallResult> active = executor.submit(() -> PackDownloader.downloadUrl(
packsFolder,
baseUrl + "/cancel-slow.zip",
false,
ignored -> {
},
cancellation
));
assertTrue(slowRequestStarted.await(5L, TimeUnit.SECONDS));
cancellation.cancel();
ExecutionException failure = assertThrows(
ExecutionException.class,
() -> active.get(5L, TimeUnit.SECONDS)
);
assertTrue(failure.getCause() instanceof PackDownloader.PackDownloadCancelledException);
releaseSlowResponse.countDown();
PackDownloader.PackInstallResult followup = PackDownloader.downloadUrl(
packsFolder,
baseUrl + "/cancel-followup.zip",
false,
ignored -> {
}
);
assertNotNull(followup);
assertEquals("cancel_followup", followup.key());
assertEquals(1, followupRequests.get());
} finally {
releaseSlowResponse.countDown();
executor.shutdownNow();
assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS));
server.stop(0);
}
}
@Test @Test
public void builtInPackPresenceRequiresItsPrimaryDimension() throws Exception { public void builtInPackPresenceRequiresItsPrimaryDimension() throws Exception {
File packsFolder = temp.newFolder("managed-presence"); File packsFolder = temp.newFolder("managed-presence");
@@ -736,6 +898,55 @@ public class PackDownloaderTest {
return pack; return pack;
} }
private byte[] packArchive(String filename, String key) throws IOException {
Path archive = temp.newFile(filename).toPath();
LinkedHashMap<String, String> entries = new LinkedHashMap<>();
entries.put(
"wrapped/dimensions/" + key + ".json",
"{\"name\":\"" + key + "\",\"regions\":[\"local\"],\"logicalHeight\":256,"
+ "\"dimensionHeight\":{\"min\":-64,\"max\":320}}"
);
entries.put("wrapped/regions/local.json", "{\"name\":\"Local\",\"landBiomes\":[\"local\"]}");
entries.put("wrapped/biomes/local.json", "{\"name\":\"Local\",\"derivative\":\"minecraft:plains\"}");
writeArchive(archive, entries);
return Files.readAllBytes(archive);
}
private static void assertBusy(Future<PackDownloader.PackInstallResult> attempt) {
ExecutionException failure = assertThrows(
ExecutionException.class,
() -> attempt.get(1L, TimeUnit.SECONDS)
);
assertTrue(failure.getCause() instanceof PackDownloader.PackDownloadBusyException);
}
private static void assertDownloadProgress(List<PackDownloader.DownloadProgress> progress, long expectedBytes) {
List<PackDownloader.DownloadPhase> phases = new ArrayList<>();
PackDownloader.DownloadPhase previousPhase = null;
long previousDownloadedBytes = -1L;
for (int index = 0; index < progress.size(); index++) {
PackDownloader.DownloadProgress update = progress.get(index);
if (update.phase() != previousPhase) {
phases.add(update.phase());
previousPhase = update.phase();
}
if (update.phase() == PackDownloader.DownloadPhase.DOWNLOADING) {
assertTrue(update.transferredBytes() >= previousDownloadedBytes);
assertEquals(expectedBytes, update.totalBytes());
previousDownloadedBytes = update.transferredBytes();
}
assertEquals(index == progress.size() - 1, update.complete());
}
assertEquals(List.of(
PackDownloader.DownloadPhase.CONNECTING,
PackDownloader.DownloadPhase.DOWNLOADING,
PackDownloader.DownloadPhase.UNPACKING,
PackDownloader.DownloadPhase.VALIDATING,
PackDownloader.DownloadPhase.PUBLISHING
), phases);
assertEquals(expectedBytes, previousDownloadedBytes);
}
private static void writeDimension(Path root, String key) throws IOException { private static void writeDimension(Path root, String key) throws IOException {
Files.writeString( Files.writeString(
root.resolve("dimensions/" + key + ".json"), root.resolve("dimensions/" + key + ".json"),
@@ -0,0 +1,219 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.pack.PackDownloader;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.volmlib.util.format.Form;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class PackDownloadProgressReporterTest {
@Test
public void determinateBarAlwaysContainsTwentyFourCells() {
String bar = PackDownloadProgressReporter.determinateBar(0.5D);
assertEquals("[" + "|".repeat(24) + "]", C.stripColor(bar));
assertEquals(12, occurrences(bar, C.GREEN.toString()));
}
@Test
public void indeterminateBarMovesFiveCellSegment() {
String first = PackDownloadProgressReporter.indeterminateBar(0L);
String moved = PackDownloadProgressReporter.indeterminateBar(1_000L);
assertEquals("[" + "|".repeat(24) + "]", C.stripColor(first));
assertEquals("[" + "|".repeat(24) + "]", C.stripColor(moved));
assertEquals(5, occurrences(first, C.AQUA.toString()));
assertEquals(5, occurrences(moved, C.AQUA.toString()));
assertFalse(first.equals(moved));
}
@Test
public void indeterminateProgressLineAnimatesWithoutAnotherTransferEvent() {
PackDownloader.DownloadProgress progress = new PackDownloader.DownloadProgress(
PackDownloader.DownloadPhase.VALIDATING,
1_000_000L,
-1L,
2_000L,
false
);
String first = PackDownloadProgressReporter.progressLine(progress, 0L);
String moved = PackDownloadProgressReporter.progressLine(progress, 1_000L);
assertFalse(first.equals(moved));
assertFalse(PackDownloadProgressReporter.indeterminateProgress(0L)
== PackDownloadProgressReporter.indeterminateProgress(1_000L));
}
@Test
public void progressLinesIncludeTransferTotalsAndRate() {
String determinate = C.stripColor(PackDownloadProgressReporter.progressLine(
new PackDownloader.DownloadProgress(
PackDownloader.DownloadPhase.DOWNLOADING,
1_000_000L,
2_000_000L,
2_000L,
false
)
));
String indeterminate = C.stripColor(PackDownloadProgressReporter.progressLine(
new PackDownloader.DownloadProgress(
PackDownloader.DownloadPhase.DOWNLOADING,
1_000_000L,
-1L,
2_000L,
false
)
));
assertTrue(determinate.contains("50%"));
assertTrue(determinate.contains(Form.fileSize(1_000_000L) + "/" + Form.fileSize(2_000_000L)));
assertTrue(determinate.contains(Form.fileSize(500_000L) + "/s"));
assertTrue(indeterminate.contains(Form.fileSize(1_000_000L)));
assertTrue(indeterminate.contains(Form.fileSize(500_000L) + "/s"));
assertFalse(indeterminate.contains("%"));
}
@Test
public void terminalPublishingEventDoesNotEraseTransferSummary() {
VolmitSender sender = mock(VolmitSender.class);
when(sender.isPlayer()).thenReturn(false);
PackDownloadProgressReporter reporter = new PackDownloadProgressReporter(sender, "overworld");
reporter.start();
reporter.onProgress(new PackDownloader.DownloadProgress(
PackDownloader.DownloadPhase.DOWNLOADING,
1_500_000L,
2_000_000L,
2_000L,
false
));
reporter.onProgress(new PackDownloader.DownloadProgress(
PackDownloader.DownloadPhase.PUBLISHING,
0L,
-1L,
0L,
true
));
reporter.succeed(new PackDownloader.PackInstallResult("overworld", true, false));
ArgumentCaptor<String> messages = ArgumentCaptor.forClass(String.class);
verify(sender, atLeastOnce()).sendMessage(messages.capture());
List<String> allMessages = messages.getAllValues();
String completion = allMessages.getLast();
assertTrue(C.stripColor(completion).contains(Form.fileSize(1_500_000L)));
assertTrue(C.stripColor(completion).contains(Form.duration(2_000L, 1)));
}
@Test
public void actionEmissionIsLimitedToFourUpdatesPerSecond() {
assertFalse(PackDownloadProgressReporter.mayEmitAction(1_000L, 1_249L));
assertTrue(PackDownloadProgressReporter.mayEmitAction(1_000L, 1_250L));
}
@Test
public void executionCompletionCancelsReporterThatNeverEnteredWorker() {
VolmitSender sender = mock(VolmitSender.class);
when(sender.isPlayer()).thenReturn(false);
PackDownloadProgressReporter reporter = new PackDownloadProgressReporter(sender, "overworld");
reporter.start();
reporter.executionComplete();
ArgumentCaptor<String> messages = ArgumentCaptor.forClass(String.class);
verify(sender, times(2)).sendMessage(messages.capture());
assertTrue(C.stripColor(messages.getAllValues().getLast()).contains("cancelled"));
}
@Test
public void signedRemoteUrlIsRedactedFromDownloaderDetails() {
String signedUrl = "https://packs.example.test/world.zip?token=secret&expires=soon";
VolmitSender sender = mock(VolmitSender.class);
when(sender.isPlayer()).thenReturn(false);
PackDownloadProgressReporter reporter = new PackDownloadProgressReporter(
sender,
"Remote ZIP",
signedUrl
);
reporter.detail("Downloading https://packs.example.test/world.zip?token=secretexpires=soon");
ArgumentCaptor<String> message = ArgumentCaptor.forClass(String.class);
verify(sender).sendMessage(message.capture());
String rendered = C.stripColor(message.getValue());
assertTrue(rendered.contains("Remote ZIP"));
assertFalse(rendered.contains("secret"));
assertFalse(rendered.contains("https://"));
}
@Test
public void listenerDisablesItselfAfterFirstDeliveryFailure() {
VolmitSender sender = mock(VolmitSender.class);
when(sender.isPlayer()).thenReturn(false);
doThrow(new IllegalStateException("delivery unavailable")).when(sender).sendMessage(anyString());
PackDownloadProgressReporter reporter = new PackDownloadProgressReporter(sender, "overworld");
PackDownloader.DownloadProgress connecting = new PackDownloader.DownloadProgress(
PackDownloader.DownloadPhase.CONNECTING,
0L,
-1L,
0L,
false
);
assertThrows(IllegalStateException.class, () -> reporter.onProgress(connecting));
reporter.onProgress(connecting);
verify(sender, times(1)).sendMessage(anyString());
}
@Test
public void playerHudUsesArbitratedActionAndBossBarLanesWithCleanup() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/art/arcane/iris/core/service/PackDownloadProgressReporter.java"
));
assertTrue(source.contains("new HudSlotRequest("));
assertTrue(source.contains("List.of(HudSurface.ACTION_BAR, HudSurface.BOSS_BAR)"));
assertTrue(source.contains("J.ar(this::pulseHud, HUD_PULSE_TICKS)"));
assertTrue(source.contains("HUD_CLAIM_TTL_MILLIS"));
assertTrue(source.contains("HUD_TERMINAL_TICKS, retiredCleanup"));
assertTrue(source.contains("BukkitPlatform.hudLanes().retire(playerId, hudLaneId)"));
assertTrue(source.contains("claim.retire();"));
assertFalse(source.contains("J.runGlobal(cleanup)"));
assertTrue(source.contains("claim.release();"));
assertTrue(source.contains("J.car(activeTaskId);"));
}
@Test
public void allDownloadPhasesHaveLocalizedLabels() {
for (PackDownloader.DownloadPhase phase : PackDownloader.DownloadPhase.values()) {
assertFalse(PackDownloadProgressReporter.phaseLabel(phase).isBlank());
}
}
private static int occurrences(String value, String match) {
int count = 0;
int offset = 0;
while ((offset = value.indexOf(match, offset)) >= 0) {
count++;
offset += match.length();
}
return count;
}
}
@@ -1,24 +1,122 @@
package art.arcane.iris.core.service; package art.arcane.iris.core.service;
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.core.localization.PackDownloadMessages;
import org.junit.Test; import org.junit.Test;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class StudioSVCPackDownloadContractTest { public class StudioSVCPackDownloadContractTest {
@Test @Test
public void downloadsRequireManualRestartWithoutMutatingLiveDatapacks() throws Exception { public void downloadsUseReporterCompletionWithoutMutatingLiveDatapacks() throws Exception {
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/service/StudioSVC.java"));
assertTrue(source.contains("reporter.succeed(result);"));
assertFalse(method(source, "public void downloadBuiltIn(VolmitSender sender, String key)")
.contains("ServerConfigurator.restart()"));
assertFalse(method(source, "public void downloadUrl(VolmitSender sender, String url)")
.contains("installDataPacksIfChanged"));
}
@Test
public void downloadLeaseIsAcquiredBeforeUnconditionalIoDispatch() throws Exception {
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/service/StudioSVC.java")); String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/service/StudioSVC.java"));
int mutationStart = source.indexOf("private void runPackMutation("); int mutationStart = source.indexOf("private void runPackMutation(");
int mutationEnd = source.indexOf("private boolean finishStandalonePackMutation(", mutationStart); int mutationEnd = source.indexOf("private void executePackMutation(", mutationStart);
int finishEnd = source.indexOf("private void runOffPrimaryThread(", mutationEnd); String runPackMutation = source.substring(mutationStart, mutationEnd);
String downloadMutation = source.substring(mutationStart, finishEnd); int leaseAcquisition = runPackMutation.indexOf("LifecycleOperationCoordinator.get().acquire(");
int executionTracking = runPackMutation.indexOf("new PackDownloadExecution(");
int ioDispatch = runPackMutation.indexOf("MultiBurst.ioBurst.submit(");
assertTrue(downloadMutation.contains("Restart the server before using the downloaded Iris pack.")); assertTrue(leaseAcquisition >= 0);
assertFalse(downloadMutation.contains("ServerConfigurator.restart()")); assertTrue(executionTracking > leaseAcquisition);
assertFalse(downloadMutation.contains("installDataPacksIfChanged")); assertTrue(ioDispatch > leaseAcquisition);
assertTrue(runPackMutation.contains("execution.bind(future);"));
assertTrue(runPackMutation.contains("execution.cancel();"));
assertTrue(runPackMutation.contains("finally"));
assertTrue(runPackMutation.contains("reporter.start();"));
assertTrue(runPackMutation.contains("reporter.executionComplete();"));
assertFalse(runPackMutation.contains("runOffPrimaryThread"));
}
@Test
public void acceptedDownloadsRouteFeedbackProgressAndTerminalStatesThroughReporter() throws Exception {
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/service/StudioSVC.java"));
String builtIn = method(source, "public void downloadBuiltIn(VolmitSender sender, String key)");
String remote = method(source, "public void downloadUrl(VolmitSender sender, String url)");
String execute = method(source, "private void executePackMutation(");
assertTrue(builtIn.contains("PackDownloadProgressReporter reporter"));
assertTrue(remote.contains("PackDownloadProgressReporter reporter"));
assertTrue(remote.contains("reporter::detail"));
assertTrue(remote.contains("\"remote-zip\", reporter"));
assertTrue(remote.contains("cancellation,"));
assertTrue(remote.contains("reporter"));
assertTrue(execute.contains("reporter.cancel();"));
assertTrue(execute.contains("reporter.fail(e);"));
}
@Test
public void shutdownClosesAdmissionAndDrainsTrackedDownloadBeforeServiceTeardown() throws Exception {
String source = Files.readString(Path.of("src/main/java/art/arcane/iris/core/service/StudioSVC.java"));
String onDisable = method(source, "public void onDisable()");
String quiesce = method(source, "public void quiesceDownloadsForShutdown()");
assertTrue(onDisable.contains("quiesceDownloadsForShutdown();"));
assertTrue(quiesce.contains("downloadAdmissionOpen = false;"));
assertTrue(quiesce.contains("execution.cancel();"));
assertTrue(quiesce.contains("execution.await("));
assertTrue(quiesce.contains("while (!execution.isComplete())"));
}
@Test
public void stackedDownloadUsesLocalizedBusyMessage() {
LifecycleOperationCoordinator.ActiveOperation download = new LifecycleOperationCoordinator.ActiveOperation(
1L,
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
LifecycleOperationCoordinator.OperationKind.PACK_DOWNLOAD,
"overworld"
);
LifecycleOperationCoordinator.ActiveOperation worldCreation = new LifecycleOperationCoordinator.ActiveOperation(
2L,
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
"iris_world"
);
assertEquals(
IrisLanguage.plain(PackDownloadMessages.IN_PROGRESS),
StudioSVC.packMutationBusyMessage(download)
);
assertEquals(
"Iris pack changes are busy with world_create for 'iris_world'. Try again when it completes.",
StudioSVC.packMutationBusyMessage(worldCreation)
);
}
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);
} }
} }
@@ -17,8 +17,13 @@ import java.io.IOException;
import java.net.InetSocketAddress; import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -112,6 +117,81 @@ public class WebCacheTest {
} }
} }
@Test
public void knownLengthReportsMonotonicStartAndFinalProgress() throws Exception {
byte[] body = "known-length-archive".getBytes(StandardCharsets.UTF_8);
HttpServer server = server(body, true);
try {
List<WebCache.TransferProgress> progress = new ArrayList<>();
File downloaded = WebCache.getNonCachedFile(
"known-progress",
url(server),
body.length,
progress::add
);
assertNotNull(downloaded);
assertTrue(progress.size() >= 2);
assertEquals(0L, progress.get(0).transferredBytes());
assertEquals(body.length, progress.get(0).contentLength());
assertEquals(0L, progress.get(0).elapsedMillis());
assertFalse(progress.get(0).complete());
assertProgressEndsAt(progress, body.length, body.length);
} finally {
server.stop(0);
}
}
@Test
public void unknownLengthReportsMonotonicStartAndFinalProgress() throws Exception {
byte[] body = "unknown-length-archive".getBytes(StandardCharsets.UTF_8);
HttpServer server = server(body, false);
try {
List<WebCache.TransferProgress> progress = new ArrayList<>();
File downloaded = WebCache.getNonCachedFile(
"unknown-progress",
url(server),
body.length,
progress::add
);
assertNotNull(downloaded);
assertTrue(progress.size() >= 2);
assertEquals(-1L, progress.get(0).contentLength());
assertEquals(0L, progress.get(0).elapsedMillis());
assertProgressEndsAt(progress, body.length, -1L);
} finally {
server.stop(0);
}
}
@Test
public void progressListenerFailureDoesNotCorruptTheDownload() throws Exception {
byte[] body = "listener-safe-archive".getBytes(StandardCharsets.UTF_8);
HttpServer server = server(body, true);
try {
AtomicInteger callbacks = new AtomicInteger();
File downloaded = WebCache.getNonCachedFile(
"listener-failure",
url(server),
body.length,
progress -> {
callbacks.incrementAndGet();
throw new IllegalStateException("listener failure");
}
);
assertNotNull(downloaded);
assertTrue(callbacks.get() >= 2);
assertEquals("listener-safe-archive", Files.readString(downloaded.toPath(), StandardCharsets.UTF_8));
} finally {
server.stop(0);
}
}
private HttpServer server(byte[] body, boolean declareLength) throws IOException { private HttpServer server(byte[] body, boolean declareLength) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/pack", exchange -> respond(exchange, body, declareLength)); server.createContext("/pack", exchange -> respond(exchange, body, declareLength));
@@ -133,4 +213,20 @@ public class WebCacheTest {
String hash = IO.hash(name + "*" + url); String hash = IO.hash(name + "*" + url);
return IrisPlatforms.get().dataFile("cache", hash.substring(0, 2), hash.substring(3, 5), hash); return IrisPlatforms.get().dataFile("cache", hash.substring(0, 2), hash.substring(3, 5), hash);
} }
private void assertProgressEndsAt(List<WebCache.TransferProgress> progress, long transferredBytes,
long contentLength) {
long previousBytes = -1L;
long previousElapsed = -1L;
for (int index = 0; index < progress.size(); index++) {
WebCache.TransferProgress update = progress.get(index);
assertTrue(update.transferredBytes() >= previousBytes);
assertTrue(update.elapsedMillis() >= previousElapsed);
assertEquals(contentLength, update.contentLength());
assertEquals(index == progress.size() - 1, update.complete());
previousBytes = update.transferredBytes();
previousElapsed = update.elapsedMillis();
}
assertEquals(transferredBytes, progress.get(progress.size() - 1).transferredBytes());
}
} }