mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-11 19:14:11 +00:00
Testing Jar Packing for minimization
This commit is contained in:
+259
@@ -0,0 +1,259 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import ca.spottedleaf.moonrise.patches.starlight.chunk.StarlightChunk;
|
||||
import ca.spottedleaf.moonrise.patches.starlight.light.SWMRNibbleArray;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.DataLayer;
|
||||
import net.minecraft.world.level.chunk.LightChunk;
|
||||
import net.minecraft.world.level.chunk.LightChunkGetter;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.lighting.BlockLightEngine;
|
||||
import net.minecraft.world.level.lighting.ChunkSkyLightSources;
|
||||
import net.minecraft.world.level.lighting.LayerLightEventListener;
|
||||
import net.minecraft.world.level.lighting.SkyLightEngine;
|
||||
import net.minecraft.world.level.material.FluidState;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
final class HeadlessChunkLighting {
|
||||
private static final int MAXIMUM_WINDOW_CHUNKS = 256;
|
||||
private static final int HALO = 2;
|
||||
|
||||
private HeadlessChunkLighting() {
|
||||
}
|
||||
|
||||
static void light(Request request) {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
throw new IllegalStateException("Offline lighting interrupted before initialization");
|
||||
}
|
||||
Window window = new Window(request);
|
||||
BlockLightEngine block = new BlockLightEngine(window);
|
||||
SkyLightEngine sky = request.skyLight() ? new SkyLightEngine(window) : null;
|
||||
for (LightingChunk chunk : window.chunks.values()) {
|
||||
ProtoChunk source = chunk.source;
|
||||
for (int section = source.getMinSectionY(); section <= source.getMaxSectionY(); section++) {
|
||||
SectionPos position = SectionPos.of(source.getPos(), section);
|
||||
boolean empty = source.getSection(source.getSectionIndexFromSectionY(section)).hasOnlyAir();
|
||||
block.updateSectionStatus(position, empty);
|
||||
if (sky != null) {
|
||||
sky.updateSectionStatus(position, empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (LightingChunk chunk : window.chunks.values()) {
|
||||
block.propagateLightSources(chunk.source.getPos());
|
||||
if (sky != null) {
|
||||
sky.propagateLightSources(chunk.source.getPos());
|
||||
}
|
||||
}
|
||||
drain(block);
|
||||
if (sky != null) {
|
||||
drain(sky);
|
||||
}
|
||||
for (ChunkPos position : request.targets()) {
|
||||
ProtoChunk chunk = window.chunks.get(position.pack()).source;
|
||||
StarlightChunk destination = (StarlightChunk) chunk;
|
||||
destination.starlight$setBlockNibbles(copyLight(block, chunk));
|
||||
destination.starlight$setSkyNibbles(copyLight(sky, chunk));
|
||||
boolean[] emptySections = new boolean[chunk.getSectionsCount()];
|
||||
for (int index = 0; index < emptySections.length; index++) {
|
||||
emptySections[index] = chunk.getSection(index).hasOnlyAir();
|
||||
}
|
||||
destination.starlight$setBlockEmptinessMap(emptySections.clone());
|
||||
destination.starlight$setSkyEmptinessMap(emptySections);
|
||||
chunk.setPersistedStatus(ChunkStatus.LIGHT);
|
||||
chunk.setLightCorrect(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void drain(LayerLightEventListener engine) {
|
||||
while (engine.hasLightWork()) {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
throw new IllegalStateException("Offline lighting interrupted before completion");
|
||||
}
|
||||
engine.runLightUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
private static SWMRNibbleArray[] copyLight(LayerLightEventListener engine, ProtoChunk chunk) {
|
||||
SWMRNibbleArray[] result = new SWMRNibbleArray[chunk.getSectionsCount() + 2];
|
||||
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
|
||||
for (int index = 0; index < result.length; index++) {
|
||||
int sectionY = chunk.getMinSectionY() + index - 1;
|
||||
DataLayer layer = engine == null ? new DataLayer() : engine.getDataLayerData(SectionPos.of(chunk.getPos(), sectionY));
|
||||
if (layer == null) {
|
||||
layer = new DataLayer();
|
||||
for (int y = 0; y < 16; y++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
position.set(chunk.getPos().getMinBlockX() + x, (sectionY << 4) + y,
|
||||
chunk.getPos().getMinBlockZ() + z);
|
||||
layer.set(x, y, z, engine.getLightValue(position));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result[index] = SWMRNibbleArray.fromVanilla(layer);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
record Request(List<ProtoChunk> chunks, Set<ChunkPos> targets, boolean skyLight) {
|
||||
Request {
|
||||
chunks = List.copyOf(chunks);
|
||||
targets = Set.copyOf(targets);
|
||||
if (chunks.isEmpty() || chunks.size() > MAXIMUM_WINDOW_CHUNKS || targets.isEmpty()) {
|
||||
throw new IllegalArgumentException("Offline lighting requires targets inside a bounded chunk window");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Window implements LightChunkGetter, BlockGetter {
|
||||
private final Map<Long, LightingChunk> chunks;
|
||||
private final int minimumY;
|
||||
private final int height;
|
||||
|
||||
private Window(Request request) {
|
||||
minimumY = request.chunks().getFirst().getMinY();
|
||||
height = request.chunks().getFirst().getHeight();
|
||||
chunks = new HashMap<>(request.chunks().size());
|
||||
for (ProtoChunk chunk : request.chunks()) {
|
||||
if (chunk.getMinY() != minimumY || chunk.getHeight() != height
|
||||
|| chunk.getPersistedStatus() != ChunkStatus.FEATURES) {
|
||||
throw new IllegalArgumentException("Offline lighting requires matching heights and completed FEATURES chunks");
|
||||
}
|
||||
if (chunks.putIfAbsent(chunk.getPos().pack(), new LightingChunk(chunk)) != null) {
|
||||
throw new IllegalArgumentException("Duplicate lighting chunk: " + chunk.getPos());
|
||||
}
|
||||
}
|
||||
for (ChunkPos target : request.targets()) {
|
||||
for (int dz = -HALO; dz <= HALO; dz++) {
|
||||
for (int dx = -HALO; dx <= HALO; dx++) {
|
||||
if (!chunks.containsKey(ChunkPos.pack(Math.addExact(target.x(), dx), Math.addExact(target.z(), dz)))) {
|
||||
throw new IllegalArgumentException("Offline lighting target lacks its completed neighbor halo: " + target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public LightChunk getChunkForLighting(int x, int z) {
|
||||
return chunks.get(ChunkPos.pack(x, z));
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockGetter getLevel() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMinY() {
|
||||
return minimumY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getHeight() {
|
||||
return height;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockEntity getBlockEntity(BlockPos position) {
|
||||
return requireChunk(position).getBlockEntity(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockState getBlockState(BlockPos position) {
|
||||
return requireChunk(position).getBlockState(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockState getBlockStateIfLoaded(BlockPos position) {
|
||||
LightingChunk chunk = chunks.get(ChunkPos.pack(position.getX() >> 4, position.getZ() >> 4));
|
||||
return chunk == null ? null : chunk.getBlockState(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FluidState getFluidState(BlockPos position) {
|
||||
return getBlockState(position).getFluidState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public FluidState getFluidIfLoaded(BlockPos position) {
|
||||
BlockState state = getBlockStateIfLoaded(position);
|
||||
return state == null ? null : state.getFluidState();
|
||||
}
|
||||
|
||||
private LightingChunk requireChunk(BlockPos position) {
|
||||
return Objects.requireNonNull(chunks.get(ChunkPos.pack(position.getX() >> 4, position.getZ() >> 4)),
|
||||
"Lighting query outside the supplied chunk window");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class LightingChunk implements LightChunk {
|
||||
private final ProtoChunk source;
|
||||
private final ChunkSkyLightSources skySources;
|
||||
|
||||
private LightingChunk(ProtoChunk source) {
|
||||
this.source = source;
|
||||
skySources = new ChunkSkyLightSources(source);
|
||||
skySources.fillFrom(source);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void findBlockLightSources(BiConsumer<BlockPos, BlockState> consumer) {
|
||||
source.findBlockLightSources(consumer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChunkSkyLightSources getSkyLightSources() {
|
||||
return skySources;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockEntity getBlockEntity(BlockPos position) {
|
||||
return source.getBlockEntity(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockState getBlockState(BlockPos position) {
|
||||
return source.getBlockState(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockState getBlockStateIfLoaded(BlockPos position) {
|
||||
return source.getBlockStateIfLoaded(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FluidState getFluidState(BlockPos position) {
|
||||
return source.getFluidState(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FluidState getFluidIfLoaded(BlockPos position) {
|
||||
return source.getFluidIfLoaded(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMinY() {
|
||||
return source.getMinY();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getHeight() {
|
||||
return source.getHeight();
|
||||
}
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import ca.spottedleaf.moonrise.patches.starlight.chunk.StarlightChunk;
|
||||
import ca.spottedleaf.moonrise.patches.starlight.light.StarLightInterface;
|
||||
import ca.spottedleaf.moonrise.patches.starlight.light.SWMRNibbleArray;
|
||||
import ca.spottedleaf.moonrise.patches.starlight.util.SaveUtil;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.NbtAccounter;
|
||||
import net.minecraft.nbt.NbtIo;
|
||||
import net.minecraft.server.packs.PackType;
|
||||
import net.minecraft.server.packs.repository.ServerPacksSource;
|
||||
import net.minecraft.server.packs.resources.MultiPackResourceManager;
|
||||
import net.minecraft.util.datafix.DataFixers;
|
||||
import net.minecraft.world.level.BlockGetter;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.LightChunk;
|
||||
import net.minecraft.world.level.chunk.LightChunkGetter;
|
||||
import net.minecraft.world.level.chunk.PalettedContainerFactory;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.chunk.UpgradeData;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.chunk.storage.SerializableChunkData;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.StructurePieceSerializationContext;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
||||
import net.minecraft.world.level.storage.LevelStorageSource;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class HeadlessChunkLightingTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void nativeLightCrossesChunkEdgesAndRoundTripsSavedNibblesWithoutServer() throws Exception {
|
||||
Fixture fixture = new Fixture(-1, -2);
|
||||
int edgeX = fixture.target.x() * 16;
|
||||
int z = fixture.target.z() * 16 + 8;
|
||||
for (ProtoChunk chunk : fixture.chunks) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int localZ = 0; localZ < 16; localZ++) {
|
||||
chunk.setBlockState(new BlockPos(chunk.getPos().getMinBlockX() + x, 0,
|
||||
chunk.getPos().getMinBlockZ() + localZ), Blocks.STONE.defaultBlockState(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
fixture.set(new BlockPos(edgeX - 1, 0, z), Blocks.AIR.defaultBlockState());
|
||||
fixture.set(new BlockPos(edgeX - 1, -12, z), Blocks.GLOWSTONE.defaultBlockState());
|
||||
fixture.finishFeatures();
|
||||
assertNull(Bukkit.getServer());
|
||||
HeadlessChunkLighting.light(fixture.request(true));
|
||||
ProtoChunk target = fixture.chunk(fixture.target);
|
||||
assertEquals(14, light(target, false, new BlockPos(edgeX, -12, z)));
|
||||
assertEquals(13, light(target, false, new BlockPos(edgeX + 1, -12, z)));
|
||||
assertEquals(14, light(target, true, new BlockPos(edgeX, -1, z)));
|
||||
assertEquals(15, light(target, true, new BlockPos(edgeX, 1, z)));
|
||||
assertEquals(0, light(target, true, new BlockPos(edgeX + 15, -1, z)));
|
||||
assertEquals(ChunkStatus.LIGHT, target.getPersistedStatus());
|
||||
assertTrue(target.isLightCorrect());
|
||||
assertEquals(ChunkStatus.FEATURES, fixture.chunk(new ChunkPos(fixture.target.x() - 2, fixture.target.z())).getPersistedStatus());
|
||||
assertFalse(fixture.chunk(new ChunkPos(fixture.target.x() - 2, fixture.target.z())).isLightCorrect());
|
||||
|
||||
try (MultiPackResourceManager resources = new MultiPackResourceManager(PackType.SERVER_DATA,
|
||||
List.of(ServerPacksSource.createVanillaPackSource().fullResources()));
|
||||
LevelStorageSource.LevelStorageAccess storage = LevelStorageSource.createDefault(
|
||||
temporary.newFolder().toPath()).createAccess("lighting")) {
|
||||
StructureTemplateManager templates = new StructureTemplateManager(resources, storage,
|
||||
DataFixers.getDataFixer(), BuiltInRegistries.BLOCK);
|
||||
HeadlessChunkSerialization.Context context = new HeadlessChunkSerialization.Context(
|
||||
new StructurePieceSerializationContext(resources, fixture.registries, templates), fixture.containers, 0L);
|
||||
CompoundTag tag = HeadlessChunkSerialization.copyOf(target, context).write();
|
||||
assertEquals("minecraft:light", tag.getStringOr("Status", ""));
|
||||
assertEquals(SaveUtil.STARLIGHT_LIGHT_VERSION, tag.getIntOr(SaveUtil.STARLIGHT_VERSION_TAG, -1));
|
||||
assertTrue(tag.contains("isLightOn"));
|
||||
assertFalse(tag.getBooleanOr("isLightOn", true));
|
||||
assertThrows(ClassCastException.class, () -> SerializableChunkData.parse(target, fixture.containers, tag));
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
NbtIo.writeCompressed(tag, bytes);
|
||||
CompoundTag restored = NbtIo.readCompressed(new ByteArrayInputStream(bytes.toByteArray()), NbtAccounter.unlimitedHeap());
|
||||
assertEquals(tag, restored);
|
||||
List<CompoundTag> sections = restored.getListOrEmpty("sections").compoundStream().toList();
|
||||
assertEquals(target.getSectionsCount() + 2, sections.size());
|
||||
for (CompoundTag section : sections) {
|
||||
int index = section.getByteOr("Y", (byte) 0) - target.getMinSectionY() + 1;
|
||||
assertTrue(section.contains(SaveUtil.BLOCKLIGHT_STATE_TAG));
|
||||
assertTrue(section.contains(SaveUtil.SKYLIGHT_STATE_TAG));
|
||||
SWMRNibbleArray block = new SWMRNibbleArray(section.getByteArray("BlockLight").orElse(null),
|
||||
section.getIntOr(SaveUtil.BLOCKLIGHT_STATE_TAG, 0));
|
||||
SWMRNibbleArray sky = new SWMRNibbleArray(section.getByteArray("SkyLight").orElse(null),
|
||||
section.getIntOr(SaveUtil.SKYLIGHT_STATE_TAG, 0));
|
||||
assertNibbleEquals(((StarlightChunk) target).starlight$getBlockNibbles()[index], block);
|
||||
assertNibbleEquals(((StarlightChunk) target).starlight$getSkyNibbles()[index], sky);
|
||||
}
|
||||
}
|
||||
assertNull(Bukkit.getServer());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeOpacityAndShapeRulesApplyAcrossTheBoundaryWithoutSkylight() {
|
||||
for (BlockState barrier : List.of(Blocks.GLASS.defaultBlockState(), Blocks.TINTED_GLASS.defaultBlockState(),
|
||||
Blocks.STONE_SLAB.defaultBlockState(), Blocks.STONE.defaultBlockState())) {
|
||||
Fixture fixture = new Fixture(0, 0);
|
||||
for (int x = -2; x <= 3; x++) {
|
||||
for (int y = -13; y <= -11; y++) {
|
||||
for (int z = 7; z <= 9; z++) {
|
||||
fixture.set(new BlockPos(x, y, z), Blocks.STONE.defaultBlockState());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int x = -1; x <= 2; x++) {
|
||||
fixture.set(new BlockPos(x, -12, 8), Blocks.AIR.defaultBlockState());
|
||||
}
|
||||
fixture.set(new BlockPos(-1, -12, 8), Blocks.GLOWSTONE.defaultBlockState());
|
||||
fixture.set(new BlockPos(0, -12, 8), barrier);
|
||||
fixture.finishFeatures();
|
||||
HeadlessChunkLighting.light(fixture.request(false));
|
||||
ProtoChunk target = fixture.chunk(fixture.target);
|
||||
int expected = barrier.is(Blocks.GLASS) || barrier.is(Blocks.STONE_SLAB) ? 13 : 0;
|
||||
assertEquals(barrier.toString(), expected, light(target, false, new BlockPos(1, -12, 8)));
|
||||
assertEquals(0, light(target, true, new BlockPos(1, 20, 8)));
|
||||
}
|
||||
assertNull(Bukkit.getServer());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptySectionsRetainImplicitSkyLightAcrossFullHeightAndPadding() {
|
||||
for (boolean sky : List.of(true, false)) {
|
||||
Fixture fixture = new Fixture(0, 0);
|
||||
fixture.finishFeatures();
|
||||
HeadlessChunkLighting.light(fixture.request(sky));
|
||||
ProtoChunk target = fixture.chunk(fixture.target);
|
||||
for (int y = target.getMinY() - 16; y <= target.getMaxY() + 16; y++) {
|
||||
assertEquals("sky y=" + y, sky ? 15 : 0, light(target, true, new BlockPos(8, y, 8)));
|
||||
assertEquals("block y=" + y, 0, light(target, false, new BlockPos(8, y, 8)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void interruptionLeavesChunkUnlitAndRetainsInterrupt() {
|
||||
Fixture fixture = new Fixture(0, 0);
|
||||
fixture.finishFeatures();
|
||||
Thread.currentThread().interrupt();
|
||||
try {
|
||||
assertThrows(IllegalStateException.class, () -> HeadlessChunkLighting.light(fixture.request(true)));
|
||||
assertTrue(Thread.currentThread().isInterrupted());
|
||||
assertFalse(fixture.chunk(fixture.target).isLightCorrect());
|
||||
assertEquals(ChunkStatus.FEATURES, fixture.chunk(fixture.target).getPersistedStatus());
|
||||
} finally {
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsIncompleteNeighborsBeforePromotingOrMutatingLight() {
|
||||
Fixture fixture = new Fixture(0, 0);
|
||||
fixture.finishFeatures();
|
||||
fixture.chunks.removeFirst();
|
||||
ProtoChunk target = fixture.chunk(fixture.target);
|
||||
SWMRNibbleArray[] previous = ((StarlightChunk) target).starlight$getBlockNibbles();
|
||||
assertThrows(IllegalArgumentException.class, () -> HeadlessChunkLighting.light(fixture.request(true)));
|
||||
assertEquals(ChunkStatus.FEATURES, target.getPersistedStatus());
|
||||
assertFalse(target.isLightCorrect());
|
||||
assertArrayEquals(previous, ((StarlightChunk) target).starlight$getBlockNibbles());
|
||||
fixture.chunks.add(new ProtoChunk(new ChunkPos(-2, -2), UpgradeData.EMPTY, target, fixture.containers, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> HeadlessChunkLighting.light(fixture.request(true)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void directStarlightRequiresLevelButNativeLightEnginesDoNot() {
|
||||
Fixture fixture = new Fixture(0, 0);
|
||||
ProtoChunk chunk = fixture.chunk(fixture.target);
|
||||
LightChunkGetter source = new LightChunkGetter() {
|
||||
@Override
|
||||
public LightChunk getChunkForLighting(int x, int z) {
|
||||
return chunk;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockGetter getLevel() {
|
||||
return chunk;
|
||||
}
|
||||
};
|
||||
assertThrows(ClassCastException.class, () -> new StarLightInterface(source, true, true, null));
|
||||
assertNull(Bukkit.getServer());
|
||||
}
|
||||
|
||||
private static int light(ProtoChunk chunk, boolean sky, BlockPos position) {
|
||||
StarlightChunk light = (StarlightChunk) chunk;
|
||||
SWMRNibbleArray[] nibbles = sky ? light.starlight$getSkyNibbles() : light.starlight$getBlockNibbles();
|
||||
return nibbles[(position.getY() >> 4) - chunk.getMinSectionY() + 1]
|
||||
.getVisible(position.getX(), position.getY(), position.getZ());
|
||||
}
|
||||
|
||||
private static void assertNibbleEquals(SWMRNibbleArray expected, SWMRNibbleArray actual) {
|
||||
for (int index = 0; index < 4096; index++) {
|
||||
assertEquals(expected.getVisible(index), actual.getVisible(index));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final RegistryAccess registries = HeadlessNativeTestRegistries.get();
|
||||
private final PalettedContainerFactory containers = PalettedContainerFactory.create(registries);
|
||||
private final List<ProtoChunk> chunks = new ArrayList<>();
|
||||
private final ChunkPos target;
|
||||
|
||||
private Fixture(int x, int z) {
|
||||
target = new ChunkPos(x, z);
|
||||
LevelHeightAccessor height = LevelHeightAccessor.create(-64, 128);
|
||||
for (int dz = -2; dz <= 2; dz++) {
|
||||
for (int dx = -2; dx <= 2; dx++) {
|
||||
chunks.add(new ProtoChunk(new ChunkPos(x + dx, z + dz), UpgradeData.EMPTY, height, containers, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ProtoChunk chunk(ChunkPos position) {
|
||||
for (ProtoChunk chunk : chunks) {
|
||||
if (chunk.getPos().equals(position)) {
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Missing fixture chunk " + position);
|
||||
}
|
||||
|
||||
private void set(BlockPos position, BlockState state) {
|
||||
chunk(new ChunkPos(position.getX() >> 4, position.getZ() >> 4)).setBlockState(position, state, 0);
|
||||
}
|
||||
|
||||
private void finishFeatures() {
|
||||
for (ProtoChunk chunk : chunks) {
|
||||
chunk.setPersistedStatus(ChunkStatus.FEATURES);
|
||||
}
|
||||
}
|
||||
|
||||
private HeadlessChunkLighting.Request request(boolean sky) {
|
||||
return new HeadlessChunkLighting.Request(chunks, Set.of(target), sky);
|
||||
}
|
||||
}
|
||||
}
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.Holder;
|
||||
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.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.flag.FeatureFlags;
|
||||
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.EntityBlock;
|
||||
import net.minecraft.world.level.block.entity.BlockEntity;
|
||||
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.ProtoChunk;
|
||||
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 net.minecraft.world.ticks.TickContainerAccess;
|
||||
import org.bukkit.event.entity.CreatureSpawnEvent;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
final class OfflineFeatureWorld implements WorldGenLevel {
|
||||
private final Options options;
|
||||
private final Map<Long, ProtoChunk> chunks = new HashMap<>();
|
||||
private final RandomSource random;
|
||||
private final BiomeManager biomeManager;
|
||||
private final WorldBorder border = new WorldBorder();
|
||||
private final LevelTickAccess<Block> blockTicks;
|
||||
private final LevelTickAccess<Fluid> fluidTicks;
|
||||
private long subTick;
|
||||
|
||||
OfflineFeatureWorld(Options options, List<ProtoChunk> chunks) {
|
||||
this.options = options;
|
||||
for (ProtoChunk chunk : chunks) {
|
||||
if (chunk.getPersistedStatus() != ChunkStatus.TERRAIN) {
|
||||
throw new IllegalArgumentException("Feature fixture requires TERRAIN chunks");
|
||||
}
|
||||
if (this.chunks.put(chunk.getPos().pack(), chunk) != null) {
|
||||
throw new IllegalArgumentException("Duplicate chunk");
|
||||
}
|
||||
}
|
||||
random = RandomSource.create(options.seed());
|
||||
biomeManager = new BiomeManager(this, BiomeManager.obfuscateSeed(options.seed()));
|
||||
blockTicks = ticks(ProtoChunk::getBlockTicks);
|
||||
fluidTicks = ticks(ProtoChunk::getFluidTicks);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getGameTime() {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerLevel getMinecraftWorld() {
|
||||
throw unsupported("ServerLevel");
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSeed() {
|
||||
return options.seed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean ensureCanWrite(BlockPos position) {
|
||||
return !isOutsideBuildHeight(position) && hasChunk(position.getX() >> 4, position.getZ() >> 4);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServerLevel getLevel() {
|
||||
throw unsupported("ServerLevel");
|
||||
}
|
||||
|
||||
@Override
|
||||
public DifficultyInstance getCurrentDifficultyAt(BlockPos position) {
|
||||
throw unsupported("getCurrentDifficultyAt");
|
||||
}
|
||||
|
||||
@Override
|
||||
public long nextSubTickCount() {
|
||||
return subTick++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LevelTickAccess<Block> getBlockTicks() {
|
||||
return blockTicks;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LevelTickAccess<Fluid> getFluidTicks() {
|
||||
return fluidTicks;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LevelData getLevelData() {
|
||||
throw unsupported("getLevelData");
|
||||
}
|
||||
|
||||
@Override
|
||||
public MinecraftServer getServer() {
|
||||
throw unsupported("getServer");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChunkSource getChunkSource() {
|
||||
throw unsupported("getChunkSource");
|
||||
}
|
||||
|
||||
@Override
|
||||
public RandomSource getRandom() {
|
||||
return random;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateNeighborsAt(BlockPos position, Block block) {
|
||||
throw unsupported("updateNeighborsAt");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void neighborShapeChanged(Direction direction, BlockPos position, BlockPos neighbourPosition, BlockState neighbourState, int updateFlags, int updateLimit) {
|
||||
throw unsupported("neighborShapeChanged");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void playSound(Entity source, BlockPos position, SoundEvent sound, SoundSource soundSource, float volume, float pitch) {
|
||||
throw unsupported("playSound");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addParticle(ParticleOptions particle, double x, double y, double z, double velocityX, double velocityY, double velocityZ) {
|
||||
throw unsupported("addParticle");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void levelEvent(Entity source, int eventId, BlockPos position, int data) {
|
||||
throw unsupported("levelEvent");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void gameEvent(Holder<GameEvent> event, Vec3 position, GameEvent.Context context) {
|
||||
throw unsupported("gameEvent");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChunkAccess getChunk(int chunkX, int chunkZ) {
|
||||
return chunk(chunkX, chunkZ);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChunkAccess getChunk(int chunkX, int chunkZ, ChunkStatus status, boolean create) {
|
||||
ProtoChunk chunk = chunks.get(ChunkPos.pack(chunkX, chunkZ));
|
||||
if (chunk == null && !create) {
|
||||
return null;
|
||||
}
|
||||
chunk = chunk(chunkX, chunkZ);
|
||||
if (!chunk.getPersistedStatus().isOrAfter(status)) {
|
||||
throw unsupported("chunk status " + status);
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChunkAccess getChunkIfLoadedImmediately(int chunkX, int chunkZ) {
|
||||
return chunks.get(ChunkPos.pack(chunkX, chunkZ));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasChunk(int chunkX, int chunkZ) {
|
||||
return chunks.containsKey(ChunkPos.pack(chunkX, chunkZ));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getHeight(Heightmap.Types type, int x, int z) {
|
||||
return chunk(x >> 4, z >> 4).getHeight(type, x & 15, z & 15) + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSkyDarken() {
|
||||
throw unsupported("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) {
|
||||
return chunk(quartX >> 2, quartZ >> 2).getNoiseBiome(quartX, quartY, quartZ);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClientSide() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSeaLevel() {
|
||||
return options.seaLevel();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DimensionType dimensionType() {
|
||||
return options.dimension();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMinY() {
|
||||
return options.dimension().minY();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getHeight() {
|
||||
return options.dimension().height();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RegistryAccess registryAccess() {
|
||||
return options.registries();
|
||||
}
|
||||
|
||||
@Override
|
||||
public FeatureFlagSet enabledFeatures() {
|
||||
return FeatureFlags.DEFAULT_FLAGS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EnvironmentAttributeReader environmentAttributes() {
|
||||
throw unsupported("environmentAttributes");
|
||||
}
|
||||
|
||||
@Override
|
||||
public LevelLightEngine getLightEngine() {
|
||||
throw unsupported("getLightEngine");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getBrightness(LightLayer layer, BlockPos position) {
|
||||
throw unsupported("getBrightness");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRawBrightness(BlockPos position, int ambientDarkening) {
|
||||
throw unsupported("getRawBrightness");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canSeeSky(BlockPos position) {
|
||||
throw unsupported("canSeeSky");
|
||||
}
|
||||
|
||||
@Override
|
||||
public WorldBorder getWorldBorder() {
|
||||
return border;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockGetter getChunkForCollisions(int chunkX, int chunkZ) {
|
||||
return chunk(chunkX, chunkZ);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockEntity getBlockEntity(BlockPos position) {
|
||||
return chunk(position).getBlockEntity(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockState getBlockState(BlockPos position) {
|
||||
return chunk(position).getBlockState(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockState getBlockStateIfLoaded(BlockPos position) {
|
||||
return hasChunk(position.getX() >> 4, position.getZ() >> 4) ? getBlockState(position) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FluidState getFluidState(BlockPos position) {
|
||||
return getBlockState(position).getFluidState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public FluidState getFluidIfLoaded(BlockPos position) {
|
||||
BlockState state = getBlockStateIfLoaded(position);
|
||||
return state == null ? null : state.getFluidState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Entity> getEntities(Entity source, AABB area, Predicate<? super Entity> predicate) {
|
||||
throw unsupported("getEntities");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends Entity> List<T> getEntities(EntityTypeTest<Entity, T> type, AABB area, Predicate<? super T> predicate) {
|
||||
throw unsupported("getEntities");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<? extends Player> players() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@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) {
|
||||
if (!ensureCanWrite(position)) {
|
||||
throw new IllegalStateException("Feature write outside prepared bounds: " + position);
|
||||
}
|
||||
ProtoChunk chunk = chunk(position);
|
||||
BlockState previous = chunk.setBlockState(position, state, updateFlags);
|
||||
if (previous == state) {
|
||||
return false;
|
||||
}
|
||||
chunk.removeBlockEntity(position);
|
||||
if (state.getBlock() instanceof EntityBlock block) {
|
||||
BlockEntity entity = block.newBlockEntity(position, state);
|
||||
if (entity != null) {
|
||||
chunk.setBlockEntity(entity);
|
||||
}
|
||||
}
|
||||
BlockPos postProcess = state.getPostProcessPos(this, position);
|
||||
if (postProcess != null) {
|
||||
chunk(postProcess).markPosForPostProcessing(postProcess);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean removeBlock(BlockPos position, boolean move) {
|
||||
return setBlock(position, Blocks.AIR.defaultBlockState(), 2, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean destroyBlock(BlockPos position, boolean drop, Entity source, int updateLimit) {
|
||||
throw unsupported("destroyBlock");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean addFreshEntity(Entity entity) {
|
||||
throw unsupported("addFreshEntity");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean addFreshEntity(Entity entity, CreatureSpawnEvent.SpawnReason reason) {
|
||||
throw unsupported("addFreshEntity");
|
||||
}
|
||||
|
||||
private ProtoChunk chunk(BlockPos position) {
|
||||
return chunk(position.getX() >> 4, position.getZ() >> 4);
|
||||
}
|
||||
|
||||
private ProtoChunk chunk(int x, int z) {
|
||||
ProtoChunk chunk = chunks.get(ChunkPos.pack(x, z));
|
||||
if (chunk == null) {
|
||||
throw new IllegalStateException("Feature escaped prepared chunk region: " + x + "," + z);
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
private <T> LevelTickAccess<T> ticks(Function<ProtoChunk, TickContainerAccess<T>> containers) {
|
||||
return new LevelTickAccess<>() {
|
||||
@Override
|
||||
public void schedule(ScheduledTick<T> tick) {
|
||||
containers.apply(chunk(tick.pos())).schedule(tick);
|
||||
}
|
||||
@Override
|
||||
public boolean hasScheduledTick(BlockPos position, T type) {
|
||||
return containers.apply(chunk(position)).hasScheduledTick(position, type);
|
||||
}
|
||||
@Override
|
||||
public int count() {
|
||||
int count = 0;
|
||||
for (ProtoChunk chunk : chunks.values()) {
|
||||
count += containers.apply(chunk).count();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@Override
|
||||
public boolean willTickThisTick(BlockPos position, T type) {
|
||||
throw unsupported("live tick execution");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static UnsupportedOperationException unsupported(String capability) {
|
||||
return new UnsupportedOperationException("Offline feature fixture has no " + capability);
|
||||
}
|
||||
|
||||
record Options(RegistryAccess registries, DimensionType dimension, long seed, int seaLevel) {
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.biome.Biomes;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.entity.ChestBlockEntity;
|
||||
import net.minecraft.world.level.chunk.PalettedContainerFactory;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.chunk.UpgradeData;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.dimension.DimensionType;
|
||||
import net.minecraft.world.level.levelgen.placement.PlacedFeature;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.StrongholdPieces;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece;
|
||||
import net.minecraft.world.level.material.Fluids;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class OfflineNativeFeatureFeasibilityTest {
|
||||
private static HeadlessNativeRegistries loaded;
|
||||
private static RegistryAccess registries;
|
||||
private static DimensionType dimension;
|
||||
|
||||
@BeforeClass
|
||||
public static void loadImportedFeatures() throws Exception {
|
||||
assertNull(Bukkit.getServer());
|
||||
loaded = HeadlessNativeRegistries.load(Map.of(
|
||||
"data/iris/worldgen/placed_feature/offline_spring.json",
|
||||
"{\"feature\":\"minecraft:spring_water\",\"placement\":[]}",
|
||||
"data/iris/worldgen/placed_feature/offline_chest.json",
|
||||
"{\"feature\":{\"type\":\"minecraft:simple_block\",\"to_place\":{\"id\":\"minecraft:chest\"}},\"placement\":[]}"
|
||||
));
|
||||
registries = loaded.registries();
|
||||
dimension = registries.lookupOrThrow(Registries.DIMENSION_TYPE).getOrThrow(ResourceKey.create(
|
||||
Registries.DIMENSION_TYPE, Identifier.parse("minecraft:overworld"))).value();
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void closeRegistries() throws Exception {
|
||||
if (loaded != null) {
|
||||
loaded.close();
|
||||
}
|
||||
assertNull(Bukkit.getServer());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registryConfiguredSpringCapturesNativeFluidTickAcrossChunkBoundary() {
|
||||
Fixture fixture = fixture();
|
||||
BlockPos spring = new BlockPos(-1, 20, 0);
|
||||
for (Direction direction : Direction.values()) {
|
||||
if (direction != Direction.EAST) {
|
||||
fixture.world().setBlock(spring.relative(direction), Blocks.STONE.defaultBlockState(), 2);
|
||||
}
|
||||
}
|
||||
assertTrue(feature("offline_spring").placement().isEmpty());
|
||||
assertTrue(feature("offline_spring").feature().value().place(fixture.world(), null, RandomSource.create(42L), spring));
|
||||
assertTrue(fixture.world().getFluidState(spring).is(Fluids.WATER));
|
||||
assertTrue(fixture.world().getFluidTicks().hasScheduledTick(spring, Fluids.WATER));
|
||||
assertEquals(1, fixture.world().getFluidTicks().count());
|
||||
ProtoChunk source = fixture.chunk(-1, 0);
|
||||
assertEquals(1, source.getTicksForSerialization(0L).fluids().size());
|
||||
assertEquals(spring, source.getTicksForSerialization(0L).fluids().getFirst().pos());
|
||||
assertFalse(source.isLightCorrect());
|
||||
assertEquals(ChunkStatus.TERRAIN, source.getPersistedStatus());
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> fixture.world().getFluidTicks().willTickThisTick(spring, Fluids.WATER));
|
||||
assertNull(Bukkit.getServer());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void registryConfiguredSimpleBlockCreatesRealNativeBlockEntity() {
|
||||
Fixture fixture = fixture();
|
||||
BlockPos chest = new BlockPos(1, 20, 1);
|
||||
assertTrue(feature("offline_chest").placement().isEmpty());
|
||||
assertTrue(feature("offline_chest").feature().value().place(fixture.world(), null, RandomSource.create(3L), chest));
|
||||
assertTrue(fixture.world().getBlockState(chest).is(Blocks.CHEST));
|
||||
assertTrue(fixture.world().getBlockEntity(chest) instanceof ChestBlockEntity);
|
||||
CompoundTag saved = fixture.chunk(0, 0).getBlockEntityNbtForSaving(chest, registries);
|
||||
assertNotNull(saved);
|
||||
assertEquals("minecraft:chest", saved.getStringOr("id", ""));
|
||||
assertEquals(1, saved.getIntOr("x", -1));
|
||||
assertEquals(20, saved.getIntOr("y", -1));
|
||||
assertEquals(ChunkStatus.TERRAIN, fixture.chunk(0, 0).getPersistedStatus());
|
||||
assertNull(Bukkit.getServer());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void importedPlacedFeatureWrapperRequiresServerLevelEvenWithoutModifiers() {
|
||||
Fixture fixture = fixture();
|
||||
BlockPos position = new BlockPos(1, 20, 1);
|
||||
for (String key : List.of("offline_spring", "offline_chest")) {
|
||||
PlacedFeature placed = feature(key);
|
||||
assertTrue(placed.placement().isEmpty());
|
||||
UnsupportedOperationException failure = assertThrows(UnsupportedOperationException.class,
|
||||
() -> placed.place(fixture.world(), null, RandomSource.create(42L), position));
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("ServerLevel"));
|
||||
assertTrue(hasFrame(failure, "net.minecraft.world.level.levelgen.placement.PlacementContext"));
|
||||
}
|
||||
assertTrue(fixture.world().getBlockState(position).isAir());
|
||||
assertEquals(0, fixture.world().getFluidTicks().count());
|
||||
assertEquals(ChunkStatus.TERRAIN, fixture.chunk(0, 0).getPersistedStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void realStrongholdPieceWritesAcrossPreparedChunksWithoutServer() {
|
||||
Fixture fixture = fixture();
|
||||
BoundingBox box = new BoundingBox(-2, 20, -2, 2, 24, 4);
|
||||
for (BlockPos position : BlockPos.betweenClosed(-2, 20, -2, 2, 24, 4)) {
|
||||
fixture.world().setBlock(position, Blocks.STONE.defaultBlockState(), 2);
|
||||
}
|
||||
StrongholdPieces.Straight piece = new StrongholdPieces.Straight(0, RandomSource.create(4L), box, Direction.SOUTH);
|
||||
piece.postProcess(fixture.world(), null, null, RandomSource.create(5L),
|
||||
box, new ChunkPos(0, 0), BlockPos.ZERO);
|
||||
assertTrue(fixture.world().getBlockState(new BlockPos(0, 22, 0)).isAir());
|
||||
assertFalse(fixture.world().getBlockState(new BlockPos(-2, 20, -2)).is(Blocks.STONE));
|
||||
assertFalse(fixture.world().getBlockState(new BlockPos(2, 20, 4)).is(Blocks.STONE));
|
||||
for (ProtoChunk chunk : fixture.chunks()) {
|
||||
assertEquals(ChunkStatus.TERRAIN, chunk.getPersistedStatus());
|
||||
}
|
||||
assertNull(Bukkit.getServer());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeLootChestRequiresCraftBukkitWorldServices() {
|
||||
Fixture fixture = fixture();
|
||||
BoundingBox box = new BoundingBox(0, 20, 0, 4, 24, 6);
|
||||
StrongholdPieces.ChestCorridor piece = new StrongholdPieces.ChestCorridor(
|
||||
0, RandomSource.create(4L), box, Direction.SOUTH);
|
||||
RuntimeException failure = assertThrows(RuntimeException.class,
|
||||
() -> piece.postProcess(fixture.world(), null, null, RandomSource.create(5L),
|
||||
box, new ChunkPos(0, 0), BlockPos.ZERO));
|
||||
assertEquals("Failed to read non-placed BlockState", failure.getMessage());
|
||||
assertTrue(failure.getCause() instanceof NullPointerException);
|
||||
assertTrue(hasFrame(failure.getCause(), "net.minecraft.world.level.storage.TagValueOutput"));
|
||||
assertTrue(hasFrame(failure, "org.bukkit.craftbukkit.block.CraftBlockStates"));
|
||||
assertEquals(ChunkStatus.TERRAIN, fixture.chunk(0, 0).getPersistedStatus());
|
||||
assertNull(Bukkit.getServer());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeHutPopulationRequiresServerLevelAndLeavesChunkUnpromoted() {
|
||||
Fixture fixture = fixture();
|
||||
for (BlockPos position : BlockPos.betweenClosed(-16, 63, -16, 31, 63, 31)) {
|
||||
fixture.world().setBlock(position, Blocks.STONE.defaultBlockState(), 2);
|
||||
}
|
||||
SwampHutPiece piece = new SwampHutPiece(RandomSource.create(9L), 0, 0);
|
||||
UnsupportedOperationException failure = assertThrows(UnsupportedOperationException.class,
|
||||
() -> piece.postProcess(fixture.world(), null, null, RandomSource.create(6L),
|
||||
new BoundingBox(-16, dimension.minY(), -16, 31, dimension.minY() + dimension.height() - 1, 31),
|
||||
new ChunkPos(0, 0), BlockPos.ZERO));
|
||||
assertTrue(failure.getMessage(), failure.getMessage().contains("ServerLevel"));
|
||||
assertTrue(hasFrame(failure, SwampHutPiece.class.getName()));
|
||||
for (ProtoChunk chunk : fixture.chunks()) {
|
||||
assertEquals(ChunkStatus.TERRAIN, chunk.getPersistedStatus());
|
||||
assertTrue(chunk.getEntities().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void finiteFixtureRejectsEscapedWritesAndUnavailableStatus() {
|
||||
Fixture fixture = fixture();
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> fixture.world().setBlock(new BlockPos(32, 20, 0), Blocks.STONE.defaultBlockState(), 2));
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> fixture.world().getChunk(0, 0, ChunkStatus.FULL, true));
|
||||
assertNull(fixture.world().getChunk(3, 3, ChunkStatus.TERRAIN, false));
|
||||
}
|
||||
|
||||
private static PlacedFeature feature(String path) {
|
||||
return registries.lookupOrThrow(Registries.PLACED_FEATURE).getOrThrow(ResourceKey.create(
|
||||
Registries.PLACED_FEATURE, Identifier.fromNamespaceAndPath("iris", path))).value();
|
||||
}
|
||||
|
||||
private static boolean hasFrame(Throwable failure, String className) {
|
||||
for (StackTraceElement frame : failure.getStackTrace()) {
|
||||
if (frame.getClassName().equals(className)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Fixture fixture() {
|
||||
List<ProtoChunk> chunks = new ArrayList<>();
|
||||
for (int z = -1; z <= 1; z++) {
|
||||
for (int x = -1; x <= 1; x++) {
|
||||
ProtoChunk chunk = new ProtoChunk(new ChunkPos(x, z), UpgradeData.EMPTY,
|
||||
LevelHeightAccessor.create(dimension.minY(), dimension.height()),
|
||||
PalettedContainerFactory.create(registries), null);
|
||||
chunk.fillBiomesFromNoise((qx, qy, qz) -> registries.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.PLAINS));
|
||||
chunk.setPersistedStatus(ChunkStatus.TERRAIN);
|
||||
chunks.add(chunk);
|
||||
}
|
||||
}
|
||||
return new Fixture(new OfflineFeatureWorld(new OfflineFeatureWorld.Options(
|
||||
registries, dimension, 69420L, 63), chunks), List.copyOf(chunks));
|
||||
}
|
||||
|
||||
private record Fixture(OfflineFeatureWorld world, List<ProtoChunk> chunks) {
|
||||
ProtoChunk chunk(int x, int z) {
|
||||
return (ProtoChunk) world.getChunk(x, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1125,3 +1125,5 @@ commands:
|
||||
from(fixtureResources)
|
||||
}
|
||||
}
|
||||
|
||||
apply from: file('gradle/packed-jar.gradle')
|
||||
|
||||
@@ -31,6 +31,7 @@ tasks.named('test').configure {
|
||||
dependencies {
|
||||
implementation(gradleApi())
|
||||
implementation('org.ow2.asm:asm:9.10.1')
|
||||
implementation('org.tukaani:xz:1.10')
|
||||
implementation('org.apache.commons:commons-compress:1.28.0')
|
||||
implementation('io.papermc.paperweight:paperweight-userdev:2.0.0-beta.21')
|
||||
testImplementation('junit:junit:4.13.2')
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import org.gradle.api.DefaultTask;
|
||||
import org.gradle.api.GradleException;
|
||||
import org.gradle.api.artifacts.component.ComponentIdentifier;
|
||||
import org.gradle.api.artifacts.component.ModuleComponentIdentifier;
|
||||
import org.gradle.api.artifacts.component.ProjectComponentIdentifier;
|
||||
import org.gradle.api.file.ConfigurableFileCollection;
|
||||
import org.gradle.api.file.RegularFileProperty;
|
||||
import org.gradle.api.provider.MapProperty;
|
||||
import org.gradle.api.tasks.Input;
|
||||
import org.gradle.api.tasks.InputFile;
|
||||
import org.gradle.api.tasks.InputFiles;
|
||||
import org.gradle.api.tasks.Internal;
|
||||
import org.gradle.api.tasks.OutputFile;
|
||||
import org.gradle.api.tasks.PathSensitive;
|
||||
import org.gradle.api.tasks.PathSensitivity;
|
||||
import org.gradle.api.tasks.TaskAction;
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassVisitor;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.Label;
|
||||
import org.objectweb.asm.MethodVisitor;
|
||||
import org.objectweb.asm.Opcodes;
|
||||
import org.tukaani.xz.LZMA2Options;
|
||||
import org.tukaani.xz.XZOutputStream;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarOutputStream;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.CRC32;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
public abstract class PackedJar extends DefaultTask {
|
||||
static final String RUNTIME = "art/arcane/iris/platform/bootstrap/PackedRuntime";
|
||||
static final String PLUGIN_BASE = "art/arcane/iris/platform/bukkit/plugin/VolmitPlugin";
|
||||
static final String PAPER_LOADER = "art/arcane/iris/IrisPluginLoader";
|
||||
static final String PAPER_BOOTSTRAP = "art/arcane/iris/IrisBootstrap";
|
||||
static final String PAYLOAD = "META-INF/iris/runtime.jar.xz";
|
||||
static final String CHECKSUM = "META-INF/iris/runtime.sha256";
|
||||
static final String NATIVE_DIRECTORY = "META-INF/iris/native/";
|
||||
|
||||
@InputFile
|
||||
@PathSensitive(PathSensitivity.NONE)
|
||||
public abstract RegularFileProperty getInputJar();
|
||||
|
||||
@InputFiles
|
||||
@PathSensitive(PathSensitivity.RELATIVE)
|
||||
public abstract ConfigurableFileCollection getBootstrapClasses();
|
||||
|
||||
@InputFiles
|
||||
@PathSensitive(PathSensitivity.NONE)
|
||||
public abstract ConfigurableFileCollection getDecoderJars();
|
||||
|
||||
@Internal
|
||||
public abstract MapProperty<String, File> getNativeArtifacts();
|
||||
|
||||
@Input
|
||||
public List<String> getNativeModules() {
|
||||
return getNativeArtifacts().get().keySet().stream().sorted().toList();
|
||||
}
|
||||
|
||||
@InputFiles
|
||||
@PathSensitive(PathSensitivity.NONE)
|
||||
public Collection<File> getNativeProviderJars() {
|
||||
return getNativeArtifacts().get().values();
|
||||
}
|
||||
|
||||
@OutputFile
|
||||
public abstract RegularFileProperty getOutputJar();
|
||||
|
||||
@TaskAction
|
||||
public void pack() throws IOException, NoSuchAlgorithmException {
|
||||
Map<String, byte[]> bootstrap = new TreeMap<>();
|
||||
for (File directory : getBootstrapClasses()) {
|
||||
try (Stream<Path> paths = Files.walk(directory.toPath())) {
|
||||
for (Path path : paths.filter(Files::isRegularFile).sorted().toList()) {
|
||||
bootstrap.put(directory.toPath().relativize(path).toString().replace(File.separatorChar, '/'),
|
||||
Files.readAllBytes(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (File decoder : getDecoderJars()) {
|
||||
for (Map.Entry<String, byte[]> entry : readJar(decoder.toPath()).entrySet()) {
|
||||
if (entry.getKey().startsWith("org/tukaani/xz/") && entry.getKey().endsWith(".class")) {
|
||||
bootstrap.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!bootstrap.containsKey(RUNTIME + ".class")) {
|
||||
throw new IOException("Packed runtime bootstrap class is missing.");
|
||||
}
|
||||
Path source = getInputJar().get().getAsFile().toPath();
|
||||
Path output = getOutputJar().get().getAsFile().toPath();
|
||||
PackResult result = writePackedJar(source, output, bootstrap, getNativeArtifacts().get());
|
||||
getLogger().lifecycle("Packed {} classes into XZ; retained {} bootstrap classes. {} -> {} bytes",
|
||||
result.totalClasses(), result.bootstrapClasses(), Files.size(source), Files.size(output));
|
||||
}
|
||||
|
||||
static PackResult writePackedJar(Path source, Path output, Map<String, byte[]> bootstrap,
|
||||
Map<String, File> nativeArtifacts)
|
||||
throws IOException, NoSuchAlgorithmException {
|
||||
Map<String, byte[]> contents = readJar(source);
|
||||
embedNativeArtifacts(contents, nativeArtifacts);
|
||||
int classCount = (int) contents.keySet().stream().filter(name -> name.endsWith(".class")).count();
|
||||
for (String name : List.of(PLUGIN_BASE, PAPER_LOADER, PAPER_BOOTSTRAP)) {
|
||||
byte[] original = contents.get(name + ".class");
|
||||
if (original == null) {
|
||||
throw new IOException("Required bootstrap entry is missing: " + name);
|
||||
}
|
||||
contents.put(name + ".class", instrument(original, name.equals(PLUGIN_BASE)));
|
||||
}
|
||||
Set<String> retained = bootstrapClosure(contents);
|
||||
contents.putAll(bootstrap);
|
||||
byte[] payload = writeJar(contents, true);
|
||||
ByteArrayOutputStream compressed = new ByteArrayOutputStream();
|
||||
LZMA2Options options = new LZMA2Options(9);
|
||||
options.setDictSize(Math.min(options.getDictSize(), Math.max(4096, payload.length)));
|
||||
try (XZOutputStream xz = new XZOutputStream(compressed, options)) {
|
||||
xz.write(payload);
|
||||
}
|
||||
Map<String, byte[]> outer = new TreeMap<>();
|
||||
for (Map.Entry<String, byte[]> entry : contents.entrySet()) {
|
||||
if (entry.getKey().startsWith(NATIVE_DIRECTORY)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.getKey().endsWith(".class")) {
|
||||
if (!retained.contains(entry.getKey())) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
outer.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
outer.putAll(bootstrap);
|
||||
outer.put(PAYLOAD, compressed.toByteArray());
|
||||
outer.put(CHECKSUM, (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload)) + "\n")
|
||||
.getBytes(StandardCharsets.US_ASCII));
|
||||
Files.createDirectories(output.toAbsolutePath().getParent());
|
||||
Files.write(output, writeJar(outer, false));
|
||||
return new PackResult(classCount, retained.size());
|
||||
}
|
||||
|
||||
public static Map<String, File> resolveNativeArtifacts(Map<ComponentIdentifier, File> resolved) {
|
||||
Map<String, File> artifacts = new TreeMap<>();
|
||||
for (Map.Entry<ComponentIdentifier, File> entry : resolved.entrySet()) {
|
||||
String module;
|
||||
if (entry.getKey() instanceof ModuleComponentIdentifier component
|
||||
&& component.getGroup().equals("com.github.VolmitSoftware.VolmLib")) {
|
||||
module = component.getModule();
|
||||
} else if (entry.getKey() instanceof ProjectComponentIdentifier component) {
|
||||
module = component.getProjectPath().substring(1).replace(':', '-');
|
||||
} else {
|
||||
throw new GradleException("Unexpected native provider component: " + entry.getKey().getDisplayName());
|
||||
}
|
||||
if (!module.matches("native-(common|v[0-9_]+R[0-9]+)")) {
|
||||
throw new GradleException("Unexpected native provider module: " + module);
|
||||
}
|
||||
if (artifacts.put(module, entry.getValue()) != null) {
|
||||
throw new GradleException("Duplicate native provider module: " + module);
|
||||
}
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
private static void embedNativeArtifacts(Map<String, byte[]> contents, Map<String, File> artifacts)
|
||||
throws IOException, NoSuchAlgorithmException {
|
||||
byte[] original = contents.get(NativeRuntimeArtifacts.MANIFEST);
|
||||
if (original == null) {
|
||||
throw new IOException("Missing native runtime dependency manifest");
|
||||
}
|
||||
Properties manifest = new Properties();
|
||||
manifest.load(new ByteArrayInputStream(original));
|
||||
Set<String> modules = new TreeSet<>(List.of(manifest.getProperty("modules", "").split(",")));
|
||||
if (!modules.equals(artifacts.keySet()) || !modules.contains("native-common")) {
|
||||
throw new IOException("Embedded native providers differ: " + artifacts.keySet() + "; expected " + modules);
|
||||
}
|
||||
List<String> embeddedManifest = new ArrayList<>();
|
||||
embeddedManifest.add("modules=" + String.join(",", modules));
|
||||
embeddedManifest.add("storage=embedded");
|
||||
for (String module : modules) {
|
||||
if (!module.matches("native-(common|v[0-9_]+R[0-9]+)")) {
|
||||
throw new IOException("Invalid embedded native provider module: " + module);
|
||||
}
|
||||
byte[] bytes = Files.readAllBytes(artifacts.get(module).toPath());
|
||||
String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
embeddedManifest.add(module + ".coordinate=com.github.VolmitSoftware.VolmLib:" + module + ":embedded-" + digest);
|
||||
embeddedManifest.add(module + ".sha256=" + digest);
|
||||
contents.put(NATIVE_DIRECTORY + module + ".jar", bytes);
|
||||
}
|
||||
contents.put(NativeRuntimeArtifacts.MANIFEST,
|
||||
(String.join("\n", embeddedManifest) + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
static Map<String, byte[]> readJar(Path path) throws IOException {
|
||||
Map<String, byte[]> contents = new TreeMap<>();
|
||||
try (ZipFile jar = new ZipFile(path.toFile())) {
|
||||
for (ZipEntry entry : jar.stream().filter(entry -> !entry.isDirectory()).toList()) {
|
||||
try (InputStream input = jar.getInputStream(entry)) {
|
||||
contents.put(entry.getName(), input.readAllBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
static byte[] writeJar(Map<String, byte[]> contents, boolean stored) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (JarOutputStream jar = new JarOutputStream(bytes)) {
|
||||
jar.setLevel(9);
|
||||
for (Map.Entry<String, byte[]> entry : contents.entrySet()) {
|
||||
JarEntry zipEntry = new JarEntry(entry.getKey());
|
||||
zipEntry.setTime(0L);
|
||||
if (stored || entry.getKey().equals(PAYLOAD)) {
|
||||
CRC32 crc = new CRC32();
|
||||
crc.update(entry.getValue());
|
||||
zipEntry.setMethod(ZipEntry.STORED);
|
||||
zipEntry.setSize(entry.getValue().length);
|
||||
zipEntry.setCompressedSize(entry.getValue().length);
|
||||
zipEntry.setCrc(crc.getValue());
|
||||
}
|
||||
jar.putNextEntry(zipEntry);
|
||||
jar.write(entry.getValue());
|
||||
jar.closeEntry();
|
||||
}
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
static Set<String> bootstrapClosure(Map<String, byte[]> contents) {
|
||||
List<String> roots = List.of("art/arcane/iris/Iris", PLUGIN_BASE, PAPER_LOADER,
|
||||
PAPER_BOOTSTRAP, "art/arcane/volmlib/integration/ReloadAware");
|
||||
ArrayDeque<String> pending = new ArrayDeque<>(roots);
|
||||
for (String root : roots) {
|
||||
byte[] bytes = contents.get(root + ".class");
|
||||
if (bytes != null) {
|
||||
pending.addAll(ClassReferences.read(bytes));
|
||||
}
|
||||
}
|
||||
for (String entry : contents.keySet()) {
|
||||
if (entry.startsWith("art/arcane/iris/util/slimjar/") && entry.endsWith(".class")) {
|
||||
pending.add(entry.substring(0, entry.length() - 6));
|
||||
}
|
||||
}
|
||||
Set<String> retained = new LinkedHashSet<>();
|
||||
while (!pending.isEmpty()) {
|
||||
String name = pending.removeFirst();
|
||||
byte[] bytes = contents.get(name + ".class");
|
||||
if (bytes == null || !retained.add(name + ".class")) {
|
||||
continue;
|
||||
}
|
||||
ClassReader reader = new ClassReader(bytes);
|
||||
if (reader.getSuperName() != null) {
|
||||
pending.add(reader.getSuperName());
|
||||
}
|
||||
for (String implemented : reader.getInterfaces()) {
|
||||
pending.add(implemented);
|
||||
}
|
||||
}
|
||||
return retained;
|
||||
}
|
||||
|
||||
static byte[] instrument(byte[] original, boolean rewriteJarAccessor) {
|
||||
ClassReader reader = new ClassReader(original);
|
||||
ClassWriter writer = new ClassWriter(reader, 0);
|
||||
reader.accept(new ClassVisitor(Opcodes.ASM9, writer) {
|
||||
private boolean hasInitializer;
|
||||
|
||||
@Override
|
||||
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
|
||||
String[] exceptions) {
|
||||
MethodVisitor method = super.visitMethod(access, name, descriptor, signature, exceptions);
|
||||
if (reader.getClassName().equals(PAPER_LOADER) && name.equals("classloader")
|
||||
&& descriptor.equals("(Lio/papermc/paper/plugin/loader/PluginClasspathBuilder;)V")) {
|
||||
return releaseTemporaryLoader(method);
|
||||
}
|
||||
if (rewriteJarAccessor && name.equals("getJarFile") && descriptor.equals("()Ljava/io/File;")) {
|
||||
method.visitCode();
|
||||
method.visitMethodInsn(Opcodes.INVOKESTATIC, RUNTIME, "runtimeJar", "()Ljava/io/File;", false);
|
||||
method.visitInsn(Opcodes.ARETURN);
|
||||
method.visitMaxs(1, 1);
|
||||
method.visitEnd();
|
||||
return null;
|
||||
}
|
||||
if (!name.equals("<clinit>")) {
|
||||
return method;
|
||||
}
|
||||
hasInitializer = true;
|
||||
return new MethodVisitor(Opcodes.ASM9, method) {
|
||||
@Override
|
||||
public void visitCode() {
|
||||
super.visitCode();
|
||||
visitMethodInsn(Opcodes.INVOKESTATIC, RUNTIME, "install", "()V", false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visitEnd() {
|
||||
if (!hasInitializer) {
|
||||
MethodVisitor initializer = super.visitMethod(Opcodes.ACC_STATIC, "<clinit>", "()V", null, null);
|
||||
initializer.visitCode();
|
||||
initializer.visitMethodInsn(Opcodes.INVOKESTATIC, RUNTIME, "install", "()V", false);
|
||||
initializer.visitInsn(Opcodes.RETURN);
|
||||
initializer.visitMaxs(0, 0);
|
||||
initializer.visitEnd();
|
||||
}
|
||||
super.visitEnd();
|
||||
}
|
||||
}, 0);
|
||||
return writer.toByteArray();
|
||||
}
|
||||
|
||||
private static MethodVisitor releaseTemporaryLoader(MethodVisitor method) {
|
||||
return new MethodVisitor(Opcodes.ASM9, method) {
|
||||
private final Label start = new Label();
|
||||
private final Label end = new Label();
|
||||
private final Label handler = new Label();
|
||||
private final Label completed = new Label();
|
||||
|
||||
@Override
|
||||
public void visitCode() {
|
||||
super.visitCode();
|
||||
super.visitLabel(start);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visitInsn(int opcode) {
|
||||
if (opcode == Opcodes.RETURN) {
|
||||
super.visitJumpInsn(Opcodes.GOTO, completed);
|
||||
} else {
|
||||
super.visitInsn(opcode);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visitMaxs(int maxStack, int maxLocals) {
|
||||
super.visitLabel(end);
|
||||
super.visitTryCatchBlock(start, end, handler, "java/lang/Throwable");
|
||||
super.visitLabel(handler);
|
||||
super.visitFrame(Opcodes.F_FULL, 0, null, 1, new Object[]{"java/lang/Throwable"});
|
||||
super.visitInsn(Opcodes.DUP);
|
||||
super.visitMethodInsn(Opcodes.INVOKESTATIC, RUNTIME, "releaseTemporary", "(Ljava/lang/Throwable;)V", false);
|
||||
super.visitInsn(Opcodes.ATHROW);
|
||||
super.visitLabel(completed);
|
||||
super.visitFrame(Opcodes.F_FULL, 0, null, 0, null);
|
||||
super.visitInsn(Opcodes.ACONST_NULL);
|
||||
super.visitMethodInsn(Opcodes.INVOKESTATIC, RUNTIME, "releaseTemporary", "(Ljava/lang/Throwable;)V", false);
|
||||
super.visitInsn(Opcodes.RETURN);
|
||||
super.visitMaxs(Math.max(maxStack, 2), maxLocals);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
record PackResult(int totalClasses, int bootstrapClasses) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import org.gradle.api.artifacts.component.ProjectComponentIdentifier;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassVisitor;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.Label;
|
||||
import org.objectweb.asm.MethodVisitor;
|
||||
import org.objectweb.asm.Opcodes;
|
||||
import org.tukaani.xz.XZInputStream;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarInputStream;
|
||||
import java.util.zip.ZipEntry;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
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.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
public class PackedJarTest {
|
||||
@Rule
|
||||
public TemporaryFolder directory = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void preservesAllRuntimeEntriesAndPublishesTheirChecksum() throws Exception {
|
||||
Path original = directory.newFile("original.jar").toPath();
|
||||
Path packed = directory.getRoot().toPath().resolve("packed.jar");
|
||||
Map<String, byte[]> contents = new LinkedHashMap<>();
|
||||
contents.put(PackedJar.PLUGIN_BASE + ".class", pluginBase());
|
||||
contents.put(PackedJar.PAPER_LOADER + ".class", simpleClass(PackedJar.PAPER_LOADER, "java/lang/Object"));
|
||||
contents.put(PackedJar.PAPER_BOOTSTRAP + ".class", simpleClass(PackedJar.PAPER_BOOTSTRAP, "java/lang/Object"));
|
||||
contents.put("art/arcane/iris/command/Example.class", simpleClass("art/arcane/iris/command/Example", "java/lang/Object"));
|
||||
contents.put("plugin.yml", "main: art.arcane.iris.Iris\n".getBytes(StandardCharsets.UTF_8));
|
||||
List<String> modules = List.of("native-common", "native-v26_2_R1", "native-v26_3_R1");
|
||||
contents.put(NativeRuntimeArtifacts.MANIFEST,
|
||||
("modules=" + String.join(",", modules) + "\nrepository=http://127.0.0.1:8765/\n")
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
Map<String, File> nativeArtifacts = new LinkedHashMap<>();
|
||||
for (String module : modules) {
|
||||
Path artifact = directory.newFile(module + ".jar").toPath();
|
||||
Files.write(artifact, PackedJar.writeJar(Map.of("provider.txt", module.getBytes(StandardCharsets.UTF_8)), false));
|
||||
nativeArtifacts.put(module, artifact.toFile());
|
||||
}
|
||||
Files.write(original, PackedJar.writeJar(contents, false));
|
||||
|
||||
Map<String, byte[]> bootstrap = Map.of(
|
||||
PackedJar.RUNTIME + ".class", simpleClass(PackedJar.RUNTIME, "java/lang/Object"),
|
||||
PackedJar.RUNTIME + "$RuntimeArchive.class", simpleClass(PackedJar.RUNTIME + "$RuntimeArchive", "java/lang/Object"),
|
||||
"org/tukaani/xz/XZInputStream.class", simpleClass("org/tukaani/xz/XZInputStream", "java/lang/Object"));
|
||||
PackedJar.PackResult result = PackedJar.writePackedJar(original, packed, bootstrap, nativeArtifacts);
|
||||
assertEquals(4, result.totalClasses());
|
||||
Map<String, byte[]> outer = PackedJar.readJar(packed);
|
||||
assertFalse(outer.containsKey("art/arcane/iris/command/Example.class"));
|
||||
assertArrayEquals(contents.get("plugin.yml"), outer.get("plugin.yml"));
|
||||
byte[] payload;
|
||||
try (XZInputStream xz = new XZInputStream(new ByteArrayInputStream(outer.get(PackedJar.PAYLOAD)))) {
|
||||
payload = xz.readAllBytes();
|
||||
}
|
||||
assertEquals(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload)),
|
||||
new String(outer.get(PackedJar.CHECKSUM), StandardCharsets.US_ASCII).trim());
|
||||
Map<String, byte[]> extracted = new LinkedHashMap<>();
|
||||
try (JarInputStream jar = new JarInputStream(new ByteArrayInputStream(payload))) {
|
||||
for (JarEntry entry = jar.getNextJarEntry(); entry != null; entry = jar.getNextJarEntry()) {
|
||||
assertEquals(ZipEntry.STORED, entry.getMethod());
|
||||
extracted.put(entry.getName(), jar.readAllBytes());
|
||||
}
|
||||
}
|
||||
Map<String, byte[]> expected = new LinkedHashMap<>(contents);
|
||||
expected.putAll(bootstrap);
|
||||
for (String module : modules) {
|
||||
expected.put(PackedJar.NATIVE_DIRECTORY + module + ".jar", Files.readAllBytes(nativeArtifacts.get(module).toPath()));
|
||||
}
|
||||
assertEquals(expected.keySet(), extracted.keySet());
|
||||
Properties nativeManifest = new Properties();
|
||||
nativeManifest.load(new ByteArrayInputStream(extracted.get(NativeRuntimeArtifacts.MANIFEST)));
|
||||
assertEquals(String.join(",", modules), nativeManifest.getProperty("modules"));
|
||||
assertEquals("embedded", nativeManifest.getProperty("storage"));
|
||||
assertNull(nativeManifest.getProperty("repository"));
|
||||
assertFalse(new String(extracted.get(NativeRuntimeArtifacts.MANIFEST), StandardCharsets.UTF_8).contains("127.0.0.1"));
|
||||
assertArrayEquals(extracted.get(NativeRuntimeArtifacts.MANIFEST), outer.get(NativeRuntimeArtifacts.MANIFEST));
|
||||
for (String module : modules) {
|
||||
String path = PackedJar.NATIVE_DIRECTORY + module + ".jar";
|
||||
byte[] artifact = Files.readAllBytes(nativeArtifacts.get(module).toPath());
|
||||
String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(artifact));
|
||||
assertArrayEquals(artifact, extracted.get(path));
|
||||
assertFalse(outer.containsKey(path));
|
||||
assertEquals(digest, nativeManifest.getProperty(module + ".sha256"));
|
||||
assertEquals("com.github.VolmitSoftware.VolmLib:" + module + ":embedded-" + digest,
|
||||
nativeManifest.getProperty(module + ".coordinate"));
|
||||
}
|
||||
for (String name : bootstrap.keySet()) {
|
||||
assertArrayEquals(bootstrap.get(name), extracted.get(name));
|
||||
assertArrayEquals(bootstrap.get(name), outer.get(name));
|
||||
}
|
||||
for (String name : contents.keySet()) {
|
||||
if (!name.equals(NativeRuntimeArtifacts.MANIFEST)
|
||||
&& !List.of(PackedJar.PLUGIN_BASE, PackedJar.PAPER_LOADER, PackedJar.PAPER_BOOTSTRAP).contains(
|
||||
name.replace(".class", ""))) {
|
||||
assertArrayEquals(contents.get(name), extracted.get(name));
|
||||
}
|
||||
}
|
||||
for (String name : List.of(PackedJar.PLUGIN_BASE, PackedJar.PAPER_LOADER, PackedJar.PAPER_BOOTSTRAP)) {
|
||||
assertArrayEquals(extracted.get(name + ".class"), outer.get(name + ".class"));
|
||||
assertEquals(1, installationCalls(extracted.get(name + ".class")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMissingOrUnexpectedEmbeddedNativeProviders() throws Exception {
|
||||
Path original = directory.newFile("original.jar").toPath();
|
||||
Path packed = directory.getRoot().toPath().resolve("packed.jar");
|
||||
byte[] manifest = "modules=native-common,native-v26_2_R1,native-v26_3_R1\n".getBytes(StandardCharsets.UTF_8);
|
||||
Files.write(original, PackedJar.writeJar(Map.of(NativeRuntimeArtifacts.MANIFEST, manifest), false));
|
||||
File common = directory.newFile("common.jar");
|
||||
assertThrows(IOException.class, () -> PackedJar.writePackedJar(original, packed, Map.of(),
|
||||
Map.of("native-common", common, "native-v26_2_R1", common)));
|
||||
assertThrows(IOException.class, () -> PackedJar.writePackedJar(original, packed, Map.of(),
|
||||
Map.of("native-common", common, "native-v26_2_R1", common, "native-v26_3_R1", common, "native-other", common)));
|
||||
assertFalse(Files.exists(packed));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesCompositeNativeModulesWithoutParsingArtifactFileNames() {
|
||||
File common = new File("arbitrary-common.jar");
|
||||
File provider = new File("arbitrary-provider.jar");
|
||||
assertEquals(Map.of("native-common", common, "native-v26_2_R1", provider),
|
||||
PackedJar.resolveNativeArtifacts(Map.of(project(":native-common"), common,
|
||||
project(":native:v26_2_R1"), provider)));
|
||||
}
|
||||
|
||||
private static ProjectComponentIdentifier project(String path) {
|
||||
return (ProjectComponentIdentifier) Proxy.newProxyInstance(PackedJarTest.class.getClassLoader(),
|
||||
new Class<?>[]{ProjectComponentIdentifier.class}, (proxy, method, arguments) -> switch (method.getName()) {
|
||||
case "getProjectPath", "getDisplayName", "toString" -> path;
|
||||
case "hashCode" -> System.identityHashCode(proxy);
|
||||
case "equals" -> proxy == arguments[0];
|
||||
default -> throw new UnsupportedOperationException(method.getName());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void installsBeforeExistingInitializationAndRedirectsTheScannerJar() {
|
||||
List<String> instructions = new ArrayList<>();
|
||||
new ClassReader(PackedJar.instrument(pluginBase(), true)).accept(new ClassVisitor(Opcodes.ASM9) {
|
||||
@Override
|
||||
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
|
||||
String[] exceptions) {
|
||||
return new MethodVisitor(Opcodes.ASM9) {
|
||||
@Override
|
||||
public void visitMethodInsn(int opcode, String owner, String methodName, String methodDescriptor,
|
||||
boolean isInterface) {
|
||||
instructions.add(name + ":" + owner + "." + methodName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void visitLdcInsn(Object value) {
|
||||
instructions.add(name + ":" + value);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, 0);
|
||||
assertEquals(List.of("<clinit>:" + PackedJar.RUNTIME + ".install", "<clinit>:existing",
|
||||
"getJarFile:" + PackedJar.RUNTIME + ".runtimeJar"), instructions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainsEntrypointReferencesAndHierarchyWithoutRecursiveMemberReferences() {
|
||||
Map<String, byte[]> contents = new LinkedHashMap<>();
|
||||
ClassWriter iris = new ClassWriter(0);
|
||||
iris.visit(Opcodes.V25, Opcodes.ACC_PUBLIC, "art/arcane/iris/Iris", null, "example/Base", null);
|
||||
iris.visitField(Opcodes.ACC_PUBLIC, "field", "Lexample/Signature;", null, null).visitEnd();
|
||||
MethodVisitor method = iris.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "later", "()V", null, null);
|
||||
method.visitCode();
|
||||
method.visitMethodInsn(Opcodes.INVOKESTATIC, "example/Body", "later", "()V", false);
|
||||
method.visitInsn(Opcodes.RETURN);
|
||||
method.visitMaxs(0, 0);
|
||||
method.visitEnd();
|
||||
iris.visitEnd();
|
||||
contents.put("art/arcane/iris/Iris.class", iris.toByteArray());
|
||||
for (String name : List.of("example/Base", "example/Signature", "example/Body")) {
|
||||
contents.put(name + ".class", simpleClass(name, "java/lang/Object"));
|
||||
}
|
||||
ClassWriter signature = new ClassWriter(0);
|
||||
signature.visit(Opcodes.V25, Opcodes.ACC_PUBLIC, "example/Signature", null, "example/Ancestor", null);
|
||||
signature.visitField(Opcodes.ACC_PUBLIC, "field", "Lexample/Chained;", null, null).visitEnd();
|
||||
signature.visitEnd();
|
||||
contents.put("example/Signature.class", signature.toByteArray());
|
||||
contents.put("example/Ancestor.class", simpleClass("example/Ancestor", "java/lang/Object"));
|
||||
contents.put("example/Chained.class", simpleClass("example/Chained", "java/lang/Object"));
|
||||
Set<String> retained = PackedJar.bootstrapClosure(contents);
|
||||
assertTrue(retained.contains("example/Base.class"));
|
||||
assertTrue(retained.contains("example/Signature.class"));
|
||||
assertTrue(retained.contains("example/Body.class"));
|
||||
assertTrue(retained.contains("example/Ancestor.class"));
|
||||
assertFalse(retained.contains("example/Chained.class"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void releasesTemporaryLoaderAfterSuccessAndPreservesThrownFailure() throws Exception {
|
||||
assertLoaderCleanup(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void releasesTemporaryLoaderWithDiscardedParameterFrameLocals() throws Exception {
|
||||
assertLoaderCleanup(true);
|
||||
}
|
||||
|
||||
private static void assertLoaderCleanup(boolean discardFrameLocals) throws Exception {
|
||||
String builderName = "io/papermc/paper/plugin/loader/PluginClasspathBuilder";
|
||||
byte[] original = loaderFixture(builderName);
|
||||
if (discardFrameLocals) {
|
||||
original = discardFrameLocals(original);
|
||||
}
|
||||
FixtureLoader fixture = new FixtureLoader(Map.of(
|
||||
builderName, simpleClass(builderName, "java/lang/Object"),
|
||||
PackedJar.RUNTIME, runtimeFixture(),
|
||||
PackedJar.PAPER_LOADER, PackedJar.instrument(original, false)));
|
||||
Class<?> runtime = fixture.loadClass(PackedJar.RUNTIME.replace('/', '.'));
|
||||
Class<?> loader = fixture.loadClass(PackedJar.PAPER_LOADER.replace('/', '.'));
|
||||
Object instance = loader.getConstructor().newInstance();
|
||||
Method invoke = loader.getMethod("classloader", fixture.loadClass(builderName.replace('/', '.')));
|
||||
invoke.invoke(instance, new Object[]{null});
|
||||
assertEquals(1, runtime.getField("releases").getInt(null));
|
||||
assertNull(runtime.getField("released").get(null));
|
||||
Throwable failure = new IllegalStateException("load failed");
|
||||
runtime.getField("failure").set(null, failure);
|
||||
try {
|
||||
invoke.invoke(instance, new Object[]{null});
|
||||
fail("Expected the original loader failure");
|
||||
} catch (InvocationTargetException caught) {
|
||||
assertSame(failure, caught.getCause());
|
||||
}
|
||||
assertEquals(2, runtime.getField("releases").getInt(null));
|
||||
assertSame(failure, runtime.getField("released").get(null));
|
||||
}
|
||||
|
||||
private static byte[] discardFrameLocals(byte[] original) {
|
||||
ClassWriter writer = new ClassWriter(0);
|
||||
new ClassReader(original).accept(new ClassVisitor(Opcodes.ASM9, writer) {
|
||||
@Override
|
||||
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
|
||||
String[] exceptions) {
|
||||
MethodVisitor method = super.visitMethod(access, name, descriptor, signature, exceptions);
|
||||
if (!name.equals("classloader")) {
|
||||
return method;
|
||||
}
|
||||
return new MethodVisitor(Opcodes.ASM9, method) {
|
||||
@Override
|
||||
public void visitFrame(int type, int numLocal, Object[] local, int numStack, Object[] stack) {
|
||||
super.visitFrame(Opcodes.F_FULL, 0, null, numStack, stack);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, ClassReader.EXPAND_FRAMES);
|
||||
return writer.toByteArray();
|
||||
}
|
||||
|
||||
private static int installationCalls(byte[] bytes) {
|
||||
List<String> calls = new ArrayList<>();
|
||||
new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) {
|
||||
@Override
|
||||
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
|
||||
String[] exceptions) {
|
||||
return new MethodVisitor(Opcodes.ASM9) {
|
||||
@Override
|
||||
public void visitMethodInsn(int opcode, String owner, String methodName, String methodDescriptor,
|
||||
boolean isInterface) {
|
||||
if (name.equals("<clinit>") && owner.equals(PackedJar.RUNTIME) && methodName.equals("install")) {
|
||||
calls.add(methodName);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}, 0);
|
||||
return calls.size();
|
||||
}
|
||||
|
||||
private static byte[] runtimeFixture() {
|
||||
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
|
||||
writer.visit(Opcodes.V25, Opcodes.ACC_PUBLIC, PackedJar.RUNTIME, null, "java/lang/Object", null);
|
||||
for (String field : List.of("failure", "released")) {
|
||||
writer.visitField(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, field, "Ljava/lang/Throwable;", null, null).visitEnd();
|
||||
}
|
||||
writer.visitField(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "releases", "I", null, null).visitEnd();
|
||||
MethodVisitor install = writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "install", "()V", null, null);
|
||||
install.visitCode();
|
||||
install.visitInsn(Opcodes.RETURN);
|
||||
install.visitMaxs(0, 0);
|
||||
install.visitEnd();
|
||||
MethodVisitor release = writer.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "releaseTemporary",
|
||||
"(Ljava/lang/Throwable;)V", null, null);
|
||||
release.visitCode();
|
||||
release.visitVarInsn(Opcodes.ALOAD, 0);
|
||||
release.visitFieldInsn(Opcodes.PUTSTATIC, PackedJar.RUNTIME, "released", "Ljava/lang/Throwable;");
|
||||
release.visitFieldInsn(Opcodes.GETSTATIC, PackedJar.RUNTIME, "releases", "I");
|
||||
release.visitInsn(Opcodes.ICONST_1);
|
||||
release.visitInsn(Opcodes.IADD);
|
||||
release.visitFieldInsn(Opcodes.PUTSTATIC, PackedJar.RUNTIME, "releases", "I");
|
||||
release.visitInsn(Opcodes.RETURN);
|
||||
release.visitMaxs(0, 0);
|
||||
release.visitEnd();
|
||||
writer.visitEnd();
|
||||
return writer.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] loaderFixture(String builderName) {
|
||||
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
|
||||
writer.visit(Opcodes.V25, Opcodes.ACC_PUBLIC, PackedJar.PAPER_LOADER, null, "java/lang/Object", null);
|
||||
MethodVisitor constructor = writer.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
|
||||
constructor.visitCode();
|
||||
constructor.visitVarInsn(Opcodes.ALOAD, 0);
|
||||
constructor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
|
||||
constructor.visitInsn(Opcodes.RETURN);
|
||||
constructor.visitMaxs(0, 0);
|
||||
constructor.visitEnd();
|
||||
MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, "classloader", "(L" + builderName + ";)V", null, null);
|
||||
method.visitCode();
|
||||
method.visitFieldInsn(Opcodes.GETSTATIC, PackedJar.RUNTIME, "failure", "Ljava/lang/Throwable;");
|
||||
method.visitInsn(Opcodes.DUP);
|
||||
Label success = new Label();
|
||||
method.visitJumpInsn(Opcodes.IFNULL, success);
|
||||
method.visitInsn(Opcodes.ATHROW);
|
||||
method.visitLabel(success);
|
||||
method.visitInsn(Opcodes.POP);
|
||||
method.visitInsn(Opcodes.RETURN);
|
||||
method.visitMaxs(0, 0);
|
||||
method.visitEnd();
|
||||
writer.visitEnd();
|
||||
return writer.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] simpleClass(String name, String parent) {
|
||||
ClassWriter writer = new ClassWriter(0);
|
||||
writer.visit(Opcodes.V25, Opcodes.ACC_PUBLIC, name, null, parent, null);
|
||||
writer.visitEnd();
|
||||
return writer.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] pluginBase() {
|
||||
ClassWriter writer = new ClassWriter(0);
|
||||
writer.visit(Opcodes.V25, Opcodes.ACC_PUBLIC, PackedJar.PLUGIN_BASE, null, "java/lang/Object", null);
|
||||
MethodVisitor initializer = writer.visitMethod(Opcodes.ACC_STATIC, "<clinit>", "()V", null, null);
|
||||
initializer.visitCode();
|
||||
initializer.visitLdcInsn("existing");
|
||||
initializer.visitInsn(Opcodes.POP);
|
||||
initializer.visitInsn(Opcodes.RETURN);
|
||||
initializer.visitMaxs(1, 0);
|
||||
initializer.visitEnd();
|
||||
MethodVisitor accessor = writer.visitMethod(Opcodes.ACC_PUBLIC, "getJarFile", "()Ljava/io/File;", null, null);
|
||||
accessor.visitCode();
|
||||
accessor.visitInsn(Opcodes.ACONST_NULL);
|
||||
accessor.visitInsn(Opcodes.ARETURN);
|
||||
accessor.visitMaxs(1, 1);
|
||||
accessor.visitEnd();
|
||||
writer.visitEnd();
|
||||
return writer.toByteArray();
|
||||
}
|
||||
|
||||
private static final class FixtureLoader extends ClassLoader {
|
||||
private final Map<String, byte[]> classes;
|
||||
|
||||
private FixtureLoader(Map<String, byte[]> classes) {
|
||||
super(PackedJarTest.class.getClassLoader());
|
||||
this.classes = classes;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> findClass(String name) throws ClassNotFoundException {
|
||||
byte[] bytes = classes.get(name.replace('.', '/'));
|
||||
if (bytes == null) {
|
||||
throw new ClassNotFoundException(name);
|
||||
}
|
||||
return defineClass(name, bytes, 0, bytes.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
@@ -60,6 +61,11 @@ public final class NativeRuntimeLibraries {
|
||||
}
|
||||
|
||||
static NativeRuntimeLibraries from(Properties manifest, String minecraftVersion) throws IOException {
|
||||
String storage = manifest.getProperty("storage", "repository");
|
||||
if (!storage.equals("repository") && !storage.equals("embedded")) {
|
||||
throw new IllegalStateException("Invalid native runtime storage: " + storage);
|
||||
}
|
||||
boolean embedded = storage.equals("embedded");
|
||||
NativeVersion version = NativeVersion.resolve(minecraftVersion).orElseThrow(
|
||||
() -> new UnsupportedOperationException("No native provider for Minecraft " + minecraftVersion));
|
||||
List<String> modules = List.of(manifest.getProperty("modules", "").split(","));
|
||||
@@ -71,13 +77,14 @@ public final class NativeRuntimeLibraries {
|
||||
String[] coordinate = manifest.getProperty(module + ".coordinate", "").split(":");
|
||||
String digest = manifest.getProperty(module + ".sha256", "");
|
||||
if (coordinate.length != 3 || !coordinate[0].equals("com.github.VolmitSoftware.VolmLib")
|
||||
|| !coordinate[1].equals(module) || coordinate[2].isBlank() || !digest.matches("[0-9a-f]{64}")) {
|
||||
|| !coordinate[1].equals(module) || coordinate[2].isBlank() || !digest.matches("[0-9a-f]{64}")
|
||||
|| embedded && !coordinate[2].equals("embedded-" + digest)) {
|
||||
throw new IllegalStateException("Invalid native dependency manifest entry: " + module);
|
||||
}
|
||||
selected.put(new Dependency(coordinate[0], coordinate[1], coordinate[2], null, List.of()), digest);
|
||||
}
|
||||
return new NativeRuntimeLibraries(Map.copyOf(selected),
|
||||
new Repository(URI.create(manifest.getProperty("repository")).toURL()));
|
||||
Repository repository = embedded ? null : new Repository(URI.create(manifest.getProperty("repository")).toURL());
|
||||
return new NativeRuntimeLibraries(Map.copyOf(selected), repository);
|
||||
}
|
||||
|
||||
public <T extends ApplicationBuilder<?>> T configure(T builder, Path downloads) {
|
||||
@@ -111,15 +118,7 @@ public final class NativeRuntimeLibraries {
|
||||
|
||||
public URLClassLoader openProviderLoader(Path downloads) {
|
||||
try {
|
||||
List<URL> urls = new ArrayList<>(dependencies.size());
|
||||
for (Map.Entry<Dependency, String> entry : dependencies.entrySet()) {
|
||||
Dependency dependency = entry.getKey();
|
||||
Path artifact = downloads.resolve(dependency.groupId().replace('.', '/'))
|
||||
.resolve(dependency.artifactId()).resolve(dependency.version())
|
||||
.resolve(dependency.artifactId() + "-" + dependency.version() + ".jar");
|
||||
verify(artifact.toFile(), entry.getValue());
|
||||
urls.add(artifact.toUri().toURL());
|
||||
}
|
||||
List<URL> urls = providerUrls(downloads, NativeRuntimeLibraries.class.getClassLoader());
|
||||
URLClassLoader loader = new URLClassLoader("Iris native providers", urls.toArray(URL[]::new),
|
||||
NativeAdapters.class.getClassLoader());
|
||||
NativeAdapters.registerProviderLoader(loader);
|
||||
@@ -130,6 +129,9 @@ public final class NativeRuntimeLibraries {
|
||||
}
|
||||
|
||||
DependencyData merge(DependencyData original) {
|
||||
if (repository == null) {
|
||||
return original;
|
||||
}
|
||||
List<Dependency> combined = new ArrayList<>(original.dependencies());
|
||||
combined.addAll(dependencies.keySet());
|
||||
List<Repository> repositories = new ArrayList<>(original.repositories());
|
||||
@@ -139,14 +141,61 @@ public final class NativeRuntimeLibraries {
|
||||
return new DependencyData(original.mirrors(), repositories, combined, original.relocations());
|
||||
}
|
||||
|
||||
List<URL> providerUrls(Path downloads, ClassLoader resources) throws IOException {
|
||||
List<URL> urls = new ArrayList<>(dependencies.size());
|
||||
for (Map.Entry<Dependency, String> entry : dependencies.entrySet()) {
|
||||
Dependency dependency = entry.getKey();
|
||||
Path artifact = downloads.resolve(dependency.groupId().replace('.', '/'))
|
||||
.resolve(dependency.artifactId()).resolve(dependency.version())
|
||||
.resolve(dependency.artifactId() + "-" + dependency.version() + ".jar");
|
||||
if (repository == null) {
|
||||
extractEmbedded(resources, dependency.artifactId(), artifact, entry.getValue());
|
||||
} else {
|
||||
verify(artifact.toFile(), entry.getValue());
|
||||
}
|
||||
urls.add(artifact.toUri().toURL());
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
private static void extractEmbedded(ClassLoader resources, String module, Path artifact, String expected) throws IOException {
|
||||
if (Files.isRegularFile(artifact) && digest(artifact).equals(expected)) {
|
||||
return;
|
||||
}
|
||||
String resource = "META-INF/iris/native/" + module + ".jar";
|
||||
try (InputStream input = resources.getResourceAsStream(resource)) {
|
||||
if (input == null) {
|
||||
throw new IOException("Missing embedded native provider: " + resource);
|
||||
}
|
||||
Files.createDirectories(artifact.getParent());
|
||||
Path temporary = Files.createTempFile(artifact.getParent(), module + "-", ".tmp");
|
||||
try {
|
||||
Files.copy(input, temporary, StandardCopyOption.REPLACE_EXISTING);
|
||||
verify(temporary.toFile(), expected);
|
||||
Files.move(temporary, artifact, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String digest(Path file) throws IOException {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
static void verify(File file, String expected) {
|
||||
try {
|
||||
String actual = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file.toPath())));
|
||||
String actual = digest(file.toPath());
|
||||
if (!actual.equals(expected)) {
|
||||
throw new DownloaderException("Native dependency does not match this Iris build: " + file.getName());
|
||||
}
|
||||
} catch (IOException | NoSuchAlgorithmException exception) {
|
||||
} catch (IOException exception) {
|
||||
throw new DownloaderException("Cannot verify native dependency: " + file, exception);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+131
@@ -3,17 +3,39 @@ package art.arcane.iris.platform.bootstrap;
|
||||
import io.github.slimjar.exceptions.DownloaderException;
|
||||
import io.github.slimjar.resolver.data.Dependency;
|
||||
import io.github.slimjar.resolver.data.DependencyData;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Arrays;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarOutputStream;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class NativeRuntimeLibrariesTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void selectsOnlyCommonAndMatchingProvider() throws Exception {
|
||||
Properties manifest = manifest();
|
||||
@@ -39,6 +61,115 @@ public class NativeRuntimeLibrariesTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void embeddedProvidersLoadWithoutRepositoryAndSelectOnlyCurrentVersion() throws Exception {
|
||||
EmbeddedFixture fixture = embeddedFixture();
|
||||
NativeRuntimeLibraries libraries = NativeRuntimeLibraries.from(fixture.manifest(), "26.2.0");
|
||||
Dependency original = new Dependency("example", "library", "1", null, List.of());
|
||||
DependencyData data = new DependencyData(List.of(), List.of(), List.of(original), List.of());
|
||||
assertSame(data, libraries.merge(data));
|
||||
Path downloads = temporary.newFolder("downloads").toPath();
|
||||
try (URLClassLoader resources = new URLClassLoader(new URL[]{fixture.resources().toUri().toURL()}, null)) {
|
||||
List<URL> providers = libraries.providerUrls(downloads, resources);
|
||||
assertEquals(2, providers.size());
|
||||
assertEquals(List.of("native-common", "native-v26_2_R1"), providers.stream()
|
||||
.map(url -> Path.of(URI.create(url.toString())).getParent().getParent().getFileName().toString())
|
||||
.sorted().toList());
|
||||
for (URL provider : providers) {
|
||||
Path artifact = Path.of(provider.toURI());
|
||||
String module = artifact.getParent().getParent().getFileName().toString();
|
||||
assertArrayEquals(Files.readAllBytes(fixture.resources().resolve("META-INF/iris/native/" + module + ".jar")),
|
||||
Files.readAllBytes(artifact));
|
||||
FileTime timestamp = FileTime.fromMillis(1000);
|
||||
Files.setLastModifiedTime(artifact, timestamp);
|
||||
libraries.providerUrls(downloads, resources);
|
||||
assertEquals(timestamp, Files.getLastModifiedTime(artifact));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void embeddedProvidersRepairCorruptCachedBytes() throws Exception {
|
||||
EmbeddedFixture fixture = embeddedFixture();
|
||||
NativeRuntimeLibraries libraries = NativeRuntimeLibraries.from(fixture.manifest(), "26.2.0");
|
||||
Path downloads = temporary.newFolder("downloads").toPath();
|
||||
try (URLClassLoader resources = new URLClassLoader(new URL[]{fixture.resources().toUri().toURL()}, null)) {
|
||||
List<URL> providers = libraries.providerUrls(downloads, resources);
|
||||
Path corrupted = Path.of(providers.getFirst().toURI());
|
||||
byte[] expected = Files.readAllBytes(corrupted);
|
||||
Files.writeString(corrupted, "truncated");
|
||||
libraries.providerUrls(downloads, resources);
|
||||
assertArrayEquals(expected, Files.readAllBytes(corrupted));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void embeddedProvidersRejectMissingResourceWithoutCachingIt() throws Exception {
|
||||
EmbeddedFixture fixture = embeddedFixture();
|
||||
Files.delete(fixture.resources().resolve("META-INF/iris/native/native-v26_2_R1.jar"));
|
||||
NativeRuntimeLibraries libraries = NativeRuntimeLibraries.from(fixture.manifest(), "26.2.0");
|
||||
Path downloads = temporary.newFolder("downloads").toPath();
|
||||
try (URLClassLoader resources = new URLClassLoader(new URL[]{fixture.resources().toUri().toURL()}, null)) {
|
||||
IOException failure = assertThrows(IOException.class, () -> libraries.providerUrls(downloads, resources));
|
||||
assertTrue(failure.getMessage().contains("native-v26_2_R1"));
|
||||
assertNoFailedExtract(downloads);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void embeddedProvidersRejectTruncatedResourceWithoutCachingIt() throws Exception {
|
||||
EmbeddedFixture fixture = embeddedFixture();
|
||||
Path resource = fixture.resources().resolve("META-INF/iris/native/native-v26_2_R1.jar");
|
||||
byte[] bytes = Files.readAllBytes(resource);
|
||||
Files.write(resource, Arrays.copyOf(bytes, bytes.length / 2));
|
||||
NativeRuntimeLibraries libraries = NativeRuntimeLibraries.from(fixture.manifest(), "26.2.0");
|
||||
Path downloads = temporary.newFolder("downloads").toPath();
|
||||
try (URLClassLoader resources = new URLClassLoader(new URL[]{fixture.resources().toUri().toURL()}, null)) {
|
||||
DownloaderException failure = assertThrows(DownloaderException.class, () -> libraries.providerUrls(downloads, resources));
|
||||
assertTrue(failure.getMessage().contains("native-v26_2_R1"));
|
||||
assertNoFailedExtract(downloads);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void embeddedCoordinatesMustMatchTheirContentHash() throws Exception {
|
||||
EmbeddedFixture fixture = embeddedFixture();
|
||||
fixture.manifest().setProperty("native-common.coordinate", "com.github.VolmitSoftware.VolmLib:native-common:revision");
|
||||
assertThrows(IllegalStateException.class, () -> NativeRuntimeLibraries.from(fixture.manifest(), "26.2.0"));
|
||||
}
|
||||
|
||||
private EmbeddedFixture embeddedFixture() throws Exception {
|
||||
Properties properties = manifest();
|
||||
properties.setProperty("storage", "embedded");
|
||||
properties.remove("repository");
|
||||
Path resources = temporary.newFolder("resources").toPath();
|
||||
Path providers = Files.createDirectories(resources.resolve("META-INF/iris/native"));
|
||||
for (String module : properties.getProperty("modules").split(",")) {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
try (JarOutputStream jar = new JarOutputStream(output)) {
|
||||
jar.putNextEntry(new JarEntry("provider.txt"));
|
||||
jar.write(module.getBytes(StandardCharsets.UTF_8));
|
||||
jar.closeEntry();
|
||||
}
|
||||
byte[] bytes = output.toByteArray();
|
||||
String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
|
||||
properties.setProperty(module + ".coordinate", "com.github.VolmitSoftware.VolmLib:" + module + ":embedded-" + hash);
|
||||
properties.setProperty(module + ".sha256", hash);
|
||||
Files.write(providers.resolve(module + ".jar"), bytes);
|
||||
}
|
||||
return new EmbeddedFixture(properties, resources);
|
||||
}
|
||||
|
||||
private static void assertNoFailedExtract(Path downloads) throws IOException {
|
||||
try (Stream<Path> files = Files.walk(downloads)) {
|
||||
assertFalse(files.anyMatch(path -> Files.isRegularFile(path)
|
||||
&& (path.getFileName().toString().endsWith(".tmp") || path.getFileName().toString().startsWith("native-v26_2_R1-"))));
|
||||
}
|
||||
}
|
||||
|
||||
private record EmbeddedFixture(Properties manifest, Path resources) {
|
||||
}
|
||||
|
||||
private static Properties manifest() {
|
||||
Properties properties = new Properties();
|
||||
List<String> modules = List.of("native-common", "native-v26_2_R1", "native-v26_3_R1");
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import org.gradle.api.tasks.compile.JavaCompile
|
||||
import org.gradle.api.artifacts.component.ComponentIdentifier
|
||||
|
||||
configurations {
|
||||
packedXz
|
||||
}
|
||||
|
||||
dependencies {
|
||||
packedXz('org.tukaani:xz:1.10')
|
||||
}
|
||||
|
||||
def originalJar = tasks.named('jar', Jar)
|
||||
sourceSets {
|
||||
packedBootstrap {
|
||||
java.srcDir('packaging/xz/src/main/java')
|
||||
compileClasspath = files(originalJar.flatMap { it.archiveFile }) + configurations.packedXz
|
||||
}
|
||||
packedBootstrapTest {
|
||||
java.srcDir('packaging/xz/src/test/java')
|
||||
compileClasspath += sourceSets.packedBootstrap.output + sourceSets.packedBootstrap.compileClasspath
|
||||
runtimeClasspath += sourceSets.packedBootstrap.output + sourceSets.packedBootstrap.compileClasspath
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
packedBootstrapTestImplementation('junit:junit:4.13.2')
|
||||
}
|
||||
|
||||
tasks.named('compilePackedBootstrapJava', JavaCompile).configure {
|
||||
dependsOn(originalJar)
|
||||
}
|
||||
|
||||
tasks.register('packedBootstrapTest', Test) {
|
||||
group = 'verification'
|
||||
testClassesDirs = sourceSets.packedBootstrapTest.output.classesDirs
|
||||
classpath = sourceSets.packedBootstrapTest.runtimeClasspath
|
||||
}
|
||||
|
||||
tasks.register('packedJar', PackedJar) {
|
||||
group = 'build'
|
||||
description = 'Builds a Bukkit jar with an embedded XZ runtime payload.'
|
||||
dependsOn(tasks.named('verifyBukkitArtifact'), tasks.named('packedBootstrapClasses'), configurations.nativeRuntime)
|
||||
inputJar.set(originalJar.flatMap { it.archiveFile })
|
||||
bootstrapClasses.from(sourceSets.packedBootstrap.output.classesDirs)
|
||||
decoderJars.from(configurations.packedXz)
|
||||
nativeArtifacts.set(providers.provider {
|
||||
Map<ComponentIdentifier, File> resolved = new LinkedHashMap<>()
|
||||
configurations.nativeRuntime.incoming.artifacts.artifacts.each { artifact ->
|
||||
resolved.put(artifact.id.componentIdentifier, artifact.file)
|
||||
}
|
||||
PackedJar.resolveNativeArtifacts(resolved)
|
||||
})
|
||||
outputJar.set(layout.buildDirectory.file(originalJar.flatMap { it.archiveFileName }
|
||||
.map { String name -> "libs/${name.replaceFirst(/\.jar$/, '-xz.jar')}" }))
|
||||
}
|
||||
|
||||
tasks.named('check').configure {
|
||||
dependsOn(tasks.named('packedBootstrapTest'))
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package art.arcane.iris.platform.bootstrap;
|
||||
|
||||
import art.arcane.iris.util.slimjar.injector.loader.factory.InjectableFactory;
|
||||
import org.tukaani.xz.XZInputStream;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public final class PackedRuntime {
|
||||
private static final long MAXIMUM_BYTES = 256L * 1024 * 1024;
|
||||
private static final int DECODER_MEMORY_KIB = 128 * 1024;
|
||||
private static Path runtime;
|
||||
private static RuntimeArchive archive;
|
||||
private static Field archiveField;
|
||||
|
||||
private PackedRuntime() {
|
||||
}
|
||||
|
||||
public static synchronized void install() {
|
||||
if (runtime != null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Path pluginJar = Path.of(PackedRuntime.class.getProtectionDomain().getCodeSource().getLocation().toURI());
|
||||
Path cache = pluginJar.getParent().resolve("Iris/cache/runtime");
|
||||
String hash;
|
||||
try (InputStream input = resource("runtime.sha256")) {
|
||||
hash = new String(input.readNBytes(128), StandardCharsets.US_ASCII).strip();
|
||||
}
|
||||
if (!hash.matches("[0-9a-f]{64}")) {
|
||||
throw new IOException("Invalid embedded runtime checksum");
|
||||
}
|
||||
Path payload = cache.resolve(hash + ".jar");
|
||||
if (Files.exists(payload)) {
|
||||
verify(payload, hash);
|
||||
} else {
|
||||
try (InputStream input = resource("runtime.jar.xz")) {
|
||||
extract(input, payload, hash);
|
||||
}
|
||||
}
|
||||
InjectableFactory.UNSAFE.create(cache, List.of(), PackedRuntime.class.getClassLoader())
|
||||
.inject(payload.toUri().toURL());
|
||||
ClassLoader loader = PackedRuntime.class.getClassLoader();
|
||||
archiveField = archiveOwner(loader).getDeclaredField("jar");
|
||||
archive = replaceArchive(loader, archiveField, payload, !temporaryLoader(loader));
|
||||
runtime = payload;
|
||||
Logger.getLogger("Iris").info("Loaded embedded XZ runtime: " + payload.getFileName());
|
||||
} catch (IOException | URISyntaxException | ReflectiveOperationException failure) {
|
||||
throw new IllegalStateException("Unable to load the embedded Iris runtime", failure);
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized void releaseTemporary(Throwable failure) {
|
||||
ClassLoader loader = PackedRuntime.class.getClassLoader();
|
||||
if (!temporaryLoader(loader) || archive == null) {
|
||||
return;
|
||||
}
|
||||
RuntimeArchive released = archive;
|
||||
archive = null;
|
||||
try (released) {
|
||||
archiveField.set(loader, released.original);
|
||||
} catch (IOException | IllegalAccessException cleanupFailure) {
|
||||
if (failure != null) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
} else {
|
||||
throw new IllegalStateException("Unable to close the Iris runtime loader archive", cleanupFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static File runtimeJar() {
|
||||
install();
|
||||
return runtime.toFile();
|
||||
}
|
||||
|
||||
static RuntimeArchive replaceArchive(ClassLoader loader, Field field, Path payload, boolean closeOriginal)
|
||||
throws IOException, IllegalAccessException {
|
||||
if (field.getType() != JarFile.class || !field.getDeclaringClass().isInstance(loader)) {
|
||||
throw new IllegalArgumentException("Unsupported plugin archive field: " + field);
|
||||
}
|
||||
field.setAccessible(true);
|
||||
JarFile original = (JarFile) field.get(loader);
|
||||
RuntimeArchive replacement = new RuntimeArchive(new ArchiveOptions(payload, original, closeOriginal));
|
||||
try {
|
||||
field.set(loader, replacement);
|
||||
} catch (IllegalAccessException | RuntimeException failure) {
|
||||
try {
|
||||
replacement.closePayload();
|
||||
} catch (IOException closeFailure) {
|
||||
failure.addSuppressed(closeFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
return replacement;
|
||||
}
|
||||
|
||||
static void extract(InputStream compressed, Path target, String expectedHash) throws IOException {
|
||||
Files.createDirectories(target.getParent());
|
||||
Path temporary = Files.createTempFile(target.getParent(), ".runtime-", ".jar");
|
||||
try {
|
||||
MessageDigest digest = digest();
|
||||
try (InputStream input = new DigestInputStream(new XZInputStream(compressed, DECODER_MEMORY_KIB), digest);
|
||||
OutputStream output = Files.newOutputStream(temporary)) {
|
||||
byte[] buffer = new byte[65536];
|
||||
long size = 0;
|
||||
int count;
|
||||
while ((count = input.read(buffer)) != -1) {
|
||||
size += count;
|
||||
if (size > MAXIMUM_BYTES) {
|
||||
throw new IOException("Embedded Iris runtime exceeds the size limit");
|
||||
}
|
||||
output.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
requireHash(digest.digest(), expectedHash);
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
static void verify(Path payload, String expectedHash) throws IOException {
|
||||
if (Files.size(payload) > MAXIMUM_BYTES) {
|
||||
throw new IOException("Embedded Iris runtime exceeds the size limit");
|
||||
}
|
||||
MessageDigest digest = digest();
|
||||
try (InputStream input = new DigestInputStream(Files.newInputStream(payload), digest)) {
|
||||
input.transferTo(OutputStream.nullOutputStream());
|
||||
}
|
||||
requireHash(digest.digest(), expectedHash);
|
||||
}
|
||||
|
||||
private static InputStream resource(String name) throws IOException {
|
||||
InputStream input = PackedRuntime.class.getResourceAsStream("/META-INF/iris/" + name);
|
||||
if (input == null) {
|
||||
throw new IOException("Missing embedded Iris runtime resource: " + name);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
private static Class<?> archiveOwner(ClassLoader loader) {
|
||||
for (Class<?> type = loader.getClass(); type != null; type = type.getSuperclass()) {
|
||||
if (type.getName().equals("org.bukkit.plugin.java.PluginClassLoader")
|
||||
|| type.getName().equals("io.papermc.paper.plugin.entrypoint.classloader.PaperSimplePluginClassLoader")) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Unsupported Iris plugin classloader: " + loader.getClass().getName());
|
||||
}
|
||||
|
||||
private static boolean temporaryLoader(ClassLoader loader) {
|
||||
return loader.getClass().getName()
|
||||
.equals("io.papermc.paper.plugin.entrypoint.classloader.PaperSimplePluginClassLoader");
|
||||
}
|
||||
|
||||
private static MessageDigest digest() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException failure) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireHash(byte[] actual, String expected) throws IOException {
|
||||
if (!HexFormat.of().formatHex(actual).equals(expected)) {
|
||||
throw new IOException("Embedded Iris runtime checksum does not match");
|
||||
}
|
||||
}
|
||||
|
||||
static final class RuntimeArchive extends JarFile {
|
||||
private final JarFile original;
|
||||
private final boolean closeOriginal;
|
||||
|
||||
RuntimeArchive(ArchiveOptions options) throws IOException {
|
||||
super(options.payload().toFile());
|
||||
this.original = options.original();
|
||||
this.closeOriginal = options.closeOriginal();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (closeOriginal) {
|
||||
try (original) {
|
||||
super.close();
|
||||
}
|
||||
} else {
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void closePayload() throws IOException {
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
|
||||
private record ArchiveOptions(Path payload, JarFile original, boolean closeOriginal) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package art.arcane.iris.platform.bootstrap;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.tukaani.xz.LZMA2Options;
|
||||
import org.tukaani.xz.XZOutputStream;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.jar.JarOutputStream;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertSame;
|
||||
|
||||
public class PackedRuntimeTest {
|
||||
@Rule
|
||||
public TemporaryFolder directory = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void extractsAndVerifiesTheExactPayload() throws Exception {
|
||||
byte[] content = "Iris runtime contents".getBytes(StandardCharsets.UTF_8);
|
||||
String hash = hash(content);
|
||||
Path target = directory.getRoot().toPath().resolve("runtime/" + hash + ".jar");
|
||||
PackedRuntime.extract(new ByteArrayInputStream(compress(content)), target, hash);
|
||||
PackedRuntime.verify(target, hash);
|
||||
assertArrayEquals(content, Files.readAllBytes(target));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsAnIncorrectHashWithoutPublishingOrLeavingTemporaryFiles() throws Exception {
|
||||
byte[] content = "Iris runtime contents".getBytes(StandardCharsets.UTF_8);
|
||||
Path target = directory.getRoot().toPath().resolve("runtime/runtime.jar");
|
||||
assertThrows(IOException.class, () -> PackedRuntime.extract(
|
||||
new ByteArrayInputStream(compress(content)), target, "0".repeat(64)));
|
||||
assertFalse(Files.exists(target));
|
||||
try (Stream<Path> files = Files.list(target.getParent())) {
|
||||
assertEquals(0L, files.count());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsTruncatedCompressedData() throws Exception {
|
||||
byte[] content = "Iris runtime contents".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] compressed = compress(content);
|
||||
Path target = directory.getRoot().toPath().resolve("runtime/runtime.jar");
|
||||
assertThrows(IOException.class, () -> PackedRuntime.extract(
|
||||
new ByteArrayInputStream(compressed, 0, compressed.length / 2), target, hash(content)));
|
||||
assertFalse(Files.exists(target));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detectsModifiedCachedRuntime() throws Exception {
|
||||
byte[] content = "Iris runtime contents".getBytes(StandardCharsets.UTF_8);
|
||||
Path target = directory.newFile("runtime.jar").toPath();
|
||||
Files.writeString(target, "changed");
|
||||
assertThrows(IOException.class, () -> PackedRuntime.verify(target, hash(content)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeArchiveReplacementKeepsClassIdentityAndClosesOwnedHandles() throws Exception {
|
||||
Path outer = createArchive("outer.jar", false);
|
||||
Path payload = createArchive("payload.jar", true);
|
||||
JarFile original = new JarFile(outer.toFile());
|
||||
PackedRuntime.RuntimeArchive replacement;
|
||||
try (ArchiveLoader loader = new ArchiveLoader(original)) {
|
||||
assertThrows(ClassNotFoundException.class, () -> loader.loadClass(Fixture.class.getName()));
|
||||
replacement = PackedRuntime.replaceArchive(loader, ArchiveLoader.class.getDeclaredField("jar"), payload, true);
|
||||
Class<?> loaded = loader.loadClass(Fixture.class.getName());
|
||||
assertSame(loader, loaded.getClassLoader());
|
||||
assertEquals("runtime", loaded.getMethod("value").invoke(null));
|
||||
assertEquals(1, loader.definitions);
|
||||
}
|
||||
assertThrows(IllegalStateException.class, original::size);
|
||||
assertThrows(IllegalStateException.class, replacement::size);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void temporaryArchiveCloseLeavesTheSharedOriginalOpen() throws Exception {
|
||||
Path outer = createArchive("outer.jar", false);
|
||||
Path payload = createArchive("payload.jar", true);
|
||||
try (JarFile original = new JarFile(outer.toFile());
|
||||
ArchiveLoader loader = new ArchiveLoader(original)) {
|
||||
PackedRuntime.RuntimeArchive replacement = PackedRuntime.replaceArchive(
|
||||
loader, ArchiveLoader.class.getDeclaredField("jar"), payload, false);
|
||||
replacement.close();
|
||||
assertEquals(0, original.size());
|
||||
assertThrows(IllegalStateException.class, replacement::size);
|
||||
}
|
||||
}
|
||||
|
||||
private Path createArchive(String name, boolean withFixture) throws IOException {
|
||||
Path path = directory.getRoot().toPath().resolve(name);
|
||||
try (JarOutputStream output = new JarOutputStream(Files.newOutputStream(path))) {
|
||||
if (withFixture) {
|
||||
String entry = Fixture.class.getName().replace('.', '/') + ".class";
|
||||
output.putNextEntry(new JarEntry(entry));
|
||||
try (InputStream bytes = getClass().getClassLoader().getResourceAsStream(entry)) {
|
||||
bytes.transferTo(output);
|
||||
}
|
||||
output.closeEntry();
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private static byte[] compress(byte[] content) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (XZOutputStream output = new XZOutputStream(bytes, new LZMA2Options(1))) {
|
||||
output.write(content);
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private static String hash(byte[] content) throws Exception {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content));
|
||||
}
|
||||
|
||||
public static class Fixture {
|
||||
public static String value() {
|
||||
return "runtime";
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ArchiveLoader extends URLClassLoader {
|
||||
private final JarFile jar;
|
||||
private int definitions;
|
||||
|
||||
private ArchiveLoader(JarFile jar) {
|
||||
super(new URL[0], ClassLoader.getPlatformClassLoader());
|
||||
this.jar = jar;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> findClass(String name) throws ClassNotFoundException {
|
||||
JarEntry entry = jar.getJarEntry(name.replace('.', '/') + ".class");
|
||||
if (entry == null) {
|
||||
throw new ClassNotFoundException(name);
|
||||
}
|
||||
try (InputStream input = jar.getInputStream(entry)) {
|
||||
byte[] bytes = input.readAllBytes();
|
||||
definitions++;
|
||||
return defineClass(name, bytes, 0, bytes.length);
|
||||
} catch (IOException failure) {
|
||||
throw new ClassNotFoundException(name, failure);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
try (jar) {
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user