From cb91e1ae9b7ff5d673d5546b4922b2b9a0f30658 Mon Sep 17 00:00:00 2001 From: FoxPack Date: Thu, 16 Jul 2026 12:53:55 +0300 Subject: [PATCH 1/3] Optimize sorting tree container lookup --- .../block/SortLogCoreBlock.java | 40 ++-- .../util/BlockCapabilityDirectionalCache.java | 31 --- .../java/twilightforest/util/WorldUtil.java | 31 +++ .../block/SortLogCoreBlockTests.java | 205 ++++++++++++++++++ .../BlockCapabilityDirectionalCacheTests.java | 50 ----- .../twilightforest/util/WorldUtilTests.java | 122 +++++++++++ 6 files changed, 375 insertions(+), 104 deletions(-) delete mode 100644 src/main/java/twilightforest/util/BlockCapabilityDirectionalCache.java create mode 100644 src/test/java/twilightforest/block/SortLogCoreBlockTests.java delete mode 100644 src/test/java/twilightforest/util/BlockCapabilityDirectionalCacheTests.java create mode 100644 src/test/java/twilightforest/util/WorldUtilTests.java diff --git a/src/main/java/twilightforest/block/SortLogCoreBlock.java b/src/main/java/twilightforest/block/SortLogCoreBlock.java index e3f5db3f3d..5e18249e89 100644 --- a/src/main/java/twilightforest/block/SortLogCoreBlock.java +++ b/src/main/java/twilightforest/block/SortLogCoreBlock.java @@ -18,7 +18,6 @@ import twilightforest.init.TFParticleType; import twilightforest.network.ParticlePacket; import twilightforest.tags.TFEntityTypeTags; -import twilightforest.util.BlockCapabilityDirectionalCache; import twilightforest.util.WorldUtil; import java.util.ArrayList; @@ -28,8 +27,6 @@ public class SortLogCoreBlock extends SpecialMagicLogBlock { - private final BlockCapabilityDirectionalCache> capabilityCache = new BlockCapabilityDirectionalCache<>(); - public SortLogCoreBlock(Properties properties) { super(properties); } @@ -39,31 +36,28 @@ public boolean doesCoreFunction() { return !TFConfig.disableSortingCore; } - //TODO fuckkkkkkkkk @Override void performTreeEffect(ServerLevel level, BlockPos pos, RandomSource rand) { Map>, Vec3> inputMap = new HashMap<>(); Map, Vec3> outputMap = new HashMap<>(); - for (BlockPos blockPos : WorldUtil.getAllAround(pos, TFConfig.sortingCoreRange)) { // Get every itemHandler from every block in the area + for (BlockEntity blockEntity : WorldUtil.getLoadedBlockEntitiesInRange(level, pos, TFConfig.sortingCoreRange)) { + BlockPos blockPos = blockEntity.getBlockPos(); if (!blockPos.equals(pos)) { - BlockEntity blockEntity = level.getBlockEntity(blockPos); - if (blockEntity != null) { - // Put it in the input if its within 2 blocks - if (Math.abs(blockPos.getX() - pos.getX()) <= 2 && Math.abs(blockPos.getY() - pos.getY()) <= 2 && Math.abs(blockPos.getZ() - pos.getZ()) <= 2) { - List> handlers = new ArrayList<>(); - for (Direction side : Direction.values()) { - ResourceHandler handler = this.capabilityCache.get(Capabilities.Item.BLOCK, level, blockPos, side); - if (handler != null) handlers.add(handler); - } - if (!handlers.isEmpty()) { - inputMap.put(handlers, Vec3.upFromBottomCenterOf(blockPos, 1.9D)); - } - } else { // Output if its outside that range - for (Direction side : Direction.values()) { - ResourceHandler handler = this.capabilityCache.get(Capabilities.Item.BLOCK, level, blockPos, side); - if (handler != null) outputMap.put(handler, Vec3.upFromBottomCenterOf(blockPos, 1.9D)); - } + // Put it in the input if its within 2 blocks + if (Math.abs(blockPos.getX() - pos.getX()) <= 2 && Math.abs(blockPos.getY() - pos.getY()) <= 2 && Math.abs(blockPos.getZ() - pos.getZ()) <= 2) { + List> handlers = new ArrayList<>(); + for (Direction side : Direction.values()) { + ResourceHandler handler = level.getCapability(Capabilities.Item.BLOCK, blockPos, blockEntity.getBlockState(), blockEntity, side); + if (handler != null) handlers.add(handler); + } + if (!handlers.isEmpty()) { + inputMap.put(handlers, Vec3.upFromBottomCenterOf(blockPos, 1.9D)); + } + } else { // Output if its outside that range + for (Direction side : Direction.values()) { + ResourceHandler handler = level.getCapability(Capabilities.Item.BLOCK, blockPos, blockEntity.getBlockState(), blockEntity, side); + if (handler != null) outputMap.put(handler, Vec3.upFromBottomCenterOf(blockPos, 1.9D)); } } } @@ -129,7 +123,7 @@ void performTreeEffect(ServerLevel level, BlockPos pos, RandomSource rand) { double x = diff.x - 0.25D + rand.nextDouble() * 0.5D; double y = diff.y - 1.75D + rand.nextDouble() * 0.5D; double z = diff.z - 0.25D + rand.nextDouble() * 0.5D; - particlePacket.queueParticle(TFParticleType.SORTING_PARTICLE.get(), false, xyz, new Vec3(x, y, z).scale(1D / diff.length())); + particlePacket.queueParticle(TFParticleType.SORTING_PARTICLE.get(), false, false, xyz, new Vec3(x, y, z).scale(1D / diff.length())); PacketDistributor.sendToPlayersNear(level, null, xyz.x(), xyz.y(), xyz.z(), 64.0D, particlePacket); break; } diff --git a/src/main/java/twilightforest/util/BlockCapabilityDirectionalCache.java b/src/main/java/twilightforest/util/BlockCapabilityDirectionalCache.java deleted file mode 100644 index 6da020b61a..0000000000 --- a/src/main/java/twilightforest/util/BlockCapabilityDirectionalCache.java +++ /dev/null @@ -1,31 +0,0 @@ -package twilightforest.util; - -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.server.level.ServerLevel; -import net.neoforged.neoforge.capabilities.BlockCapability; -import net.neoforged.neoforge.capabilities.BlockCapabilityCache; - -import javax.annotation.Nullable; -import java.util.HashMap; -import java.util.Map; - -// Voidscape copy -public class BlockCapabilityDirectionalCache { - - private final Map> data = new HashMap<>(); - - @Nullable - public R get(BlockCapability capability, ServerLevel level, BlockPos pos, Direction direction) { - BlockCapabilityCache cache = this.data.get(new BlockPosAndDirection(pos, direction)); - if (cache == null) { - cache = BlockCapabilityCache.create(capability, level, pos, direction); - this.data.put(new BlockPosAndDirection(pos, direction), cache); - } - return cache.getCapability(); - } - - private record BlockPosAndDirection(BlockPos pos, Direction direction) { - - } -} diff --git a/src/main/java/twilightforest/util/WorldUtil.java b/src/main/java/twilightforest/util/WorldUtil.java index 555cdda088..661b678000 100644 --- a/src/main/java/twilightforest/util/WorldUtil.java +++ b/src/main/java/twilightforest/util/WorldUtil.java @@ -10,6 +10,7 @@ import net.minecraft.core.Holder; import net.minecraft.core.HolderSet; import net.minecraft.core.RegistryAccess; +import net.minecraft.core.SectionPos; import net.minecraft.server.level.ServerChunkCache; import net.minecraft.server.level.ServerLevel; import net.minecraft.util.Mth; @@ -20,6 +21,7 @@ import net.minecraft.world.level.LevelAccessor; import net.minecraft.world.level.StructureManager; import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.structure.Structure; @@ -61,6 +63,35 @@ public static Iterable getAllAround(BlockPos center, int range) { return BlockPos.betweenClosed(center.offset(-range, -range, -range), center.offset(range, range, range)); } + /** + * Finds block entities in loaded chunks within a cube around {@code center}, inclusive of its edges. + * This avoids loading chunks or scanning every block position in the cube. + */ + public static List getLoadedBlockEntitiesInRange(ServerLevel level, BlockPos center, int range) { + int minX = center.getX() - range; + int minY = center.getY() - range; + int minZ = center.getZ() - range; + int maxX = center.getX() + range; + int maxY = center.getY() + range; + int maxZ = center.getZ() + range; + ChunkPos minChunk = new ChunkPos(SectionPos.blockToSectionCoord(minX), SectionPos.blockToSectionCoord(minZ)); + ChunkPos maxChunk = new ChunkPos(SectionPos.blockToSectionCoord(maxX), SectionPos.blockToSectionCoord(maxZ)); + ServerChunkCache chunkSource = level.getChunkSource(); + + return ChunkPos.rangeClosed(minChunk, maxChunk) + .map(chunkPos -> chunkSource.getChunkNow(chunkPos.x(), chunkPos.z())) + .filter(Objects::nonNull) + .flatMap(chunk -> chunk.getBlockEntities().values().stream()) + .filter(blockEntity -> !blockEntity.isRemoved()) + .filter(blockEntity -> { + BlockPos blockPos = blockEntity.getBlockPos(); + return blockPos.getX() >= minX && blockPos.getX() <= maxX + && blockPos.getY() >= minY && blockPos.getY() <= maxY + && blockPos.getZ() >= minZ && blockPos.getZ() <= maxZ; + }) + .toList(); + } + /** * Floors both corners of the bounding box to integers * Inclusive of edges diff --git a/src/test/java/twilightforest/block/SortLogCoreBlockTests.java b/src/test/java/twilightforest/block/SortLogCoreBlockTests.java new file mode 100644 index 0000000000..0c30d62af6 --- /dev/null +++ b/src/test/java/twilightforest/block/SortLogCoreBlockTests.java @@ -0,0 +1,205 @@ +package twilightforest.block; + +import com.mojang.serialization.Codec; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.phys.AABB; +import net.neoforged.neoforge.capabilities.Capabilities; +import net.neoforged.neoforge.network.PacketDistributor; +import net.neoforged.neoforge.transfer.ResourceStacksResourceHandler; +import net.neoforged.neoforge.transfer.item.ItemResource; +import net.neoforged.neoforge.transfer.resource.ResourceStack; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.MockedStatic; +import tamaized.beanification.junit.MockitoFixer; +import twilightforest.config.TFConfig; +import twilightforest.network.ParticlePacket; + +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoFixer.class) +public class SortLogCoreBlockTests { + + @Test + public void transfersItemsAcrossAChunkBoundary() { + int originalRange = TFConfig.sortingCoreRange; + TFConfig.sortingCoreRange = 4; + + try { + TreeTestFixture fixture = treeTestFixture(64); + + try (MockedStatic packets = mockStatic(PacketDistributor.class)) { + runTreeEffect(fixture); + packets.verify(() -> PacketDistributor.sendToPlayersNear( + same(fixture.level()), isNull(), anyDouble(), anyDouble(), anyDouble(), anyDouble(), any(ParticlePacket.class) + )); + } + + assertEquals(1, fixture.input().getAmountAsInt(0)); + assertEquals(2, fixture.output().getAmountAsInt(0)); + verify(fixture.level(), times(Direction.values().length)).getCapability( + eq(Capabilities.Item.BLOCK), + eq(fixture.inputPos()), + same(fixture.inputState()), + same(fixture.inputBlockEntity()), + any(Direction.class) + ); + verify(fixture.level(), times(Direction.values().length)).getCapability( + eq(Capabilities.Item.BLOCK), + eq(fixture.outputPos()), + same(fixture.outputState()), + same(fixture.outputBlockEntity()), + any(Direction.class) + ); + } finally { + TFConfig.sortingCoreRange = originalRange; + } + } + + @Test + public void rollsBackExtractionWhenTheOutputIsFull() { + int originalRange = TFConfig.sortingCoreRange; + TFConfig.sortingCoreRange = 4; + + try { + TreeTestFixture fixture = treeTestFixture(1); + + try (MockedStatic packets = mockStatic(PacketDistributor.class)) { + runTreeEffect(fixture); + packets.verifyNoInteractions(); + } + + assertEquals(2, fixture.input().getAmountAsInt(0)); + assertEquals(1, fixture.output().getAmountAsInt(0)); + } finally { + TFConfig.sortingCoreRange = originalRange; + } + } + + private static TreeTestFixture treeTestFixture(int outputCapacity) { + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunkSource = mock(ServerChunkCache.class); + LevelChunk inputChunk = mock(LevelChunk.class); + LevelChunk outputChunk = mock(LevelChunk.class); + BlockPos corePos = new BlockPos(15, 64, 0); + BlockPos inputPos = new BlockPos(16, 64, 0); + BlockPos outputPos = new BlockPos(11, 64, 0); + BlockEntity inputBlockEntity = blockEntityAt(inputPos); + BlockEntity outputBlockEntity = blockEntityAt(outputPos); + BlockState inputState = inputBlockEntity.getBlockState(); + BlockState outputState = outputBlockEntity.getBlockState(); + ItemResource empty = mock(ItemResource.class); + ItemResource diamonds = mock(ItemResource.class); + when(empty.isEmpty()).thenReturn(true); + when(diamonds.isEmpty()).thenReturn(false); + TestItemHandler input = new TestItemHandler(empty, 64); + TestItemHandler output = new TestItemHandler(empty, outputCapacity); + input.set(0, diamonds, 2); + output.set(0, diamonds, 1); + + when(level.getChunkSource()).thenReturn(chunkSource); + when(chunkSource.getChunkNow(0, 0)).thenReturn(outputChunk); + when(chunkSource.getChunkNow(1, 0)).thenReturn(inputChunk); + when(inputChunk.getBlockEntities()).thenReturn(Map.of(inputPos, inputBlockEntity)); + when(outputChunk.getBlockEntities()).thenReturn(Map.of(outputPos, outputBlockEntity)); + when(level.getCapability( + eq(Capabilities.Item.BLOCK), + eq(inputPos), + same(inputState), + same(inputBlockEntity), + any(Direction.class) + )).thenReturn(input); + when(level.getCapability( + eq(Capabilities.Item.BLOCK), + eq(outputPos), + same(outputState), + same(outputBlockEntity), + any(Direction.class) + )).thenReturn(output); + when(level.getEntities( + isNull(Entity.class), + any(AABB.class), + org.mockito.ArgumentMatchers.>any() + )).thenReturn(List.of()); + + return new TreeTestFixture( + level, + corePos, + inputPos, + outputPos, + inputBlockEntity, + outputBlockEntity, + inputState, + outputState, + input, + output + ); + } + + private static void runTreeEffect(TreeTestFixture fixture) { + // Constructing an unregistered block after Minecraft freezes its registries is not allowed in unit tests. + mock(SortLogCoreBlock.class, Answers.CALLS_REAL_METHODS) + .performTreeEffect(fixture.level(), fixture.corePos(), RandomSource.create(0L)); + } + + private static BlockEntity blockEntityAt(BlockPos pos) { + BlockEntity blockEntity = mock(BlockEntity.class); + BlockState state = mock(BlockState.class); + when(blockEntity.getBlockPos()).thenReturn(pos); + when(blockEntity.getBlockState()).thenReturn(state); + when(blockEntity.isRemoved()).thenReturn(false); + return blockEntity; + } + + private static final class TestItemHandler extends ResourceStacksResourceHandler { + + private final int capacity; + + private TestItemHandler(ItemResource emptyResource, int capacity) { + super(1, emptyResource, Codec.STRING.xmap(ignored -> new ResourceStack<>(emptyResource, 0), ignored -> "")); + this.capacity = capacity; + } + + @Override + protected int getCapacity(int index, ItemResource resource) { + return this.capacity; + } + } + + private record TreeTestFixture( + ServerLevel level, + BlockPos corePos, + BlockPos inputPos, + BlockPos outputPos, + BlockEntity inputBlockEntity, + BlockEntity outputBlockEntity, + BlockState inputState, + BlockState outputState, + TestItemHandler input, + TestItemHandler output + ) { + } +} diff --git a/src/test/java/twilightforest/util/BlockCapabilityDirectionalCacheTests.java b/src/test/java/twilightforest/util/BlockCapabilityDirectionalCacheTests.java deleted file mode 100644 index 2e6237185a..0000000000 --- a/src/test/java/twilightforest/util/BlockCapabilityDirectionalCacheTests.java +++ /dev/null @@ -1,50 +0,0 @@ -package twilightforest.util; - -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.server.level.ServerLevel; -import net.neoforged.neoforge.capabilities.BlockCapability; -import net.neoforged.neoforge.capabilities.BlockCapabilityCache; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.MockedStatic; -import tamaized.beanification.junit.MockitoFixer; - -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoFixer.class) -public class BlockCapabilityDirectionalCacheTests { - - private BlockCapabilityDirectionalCache instance; - - @BeforeEach - public void setup() { - instance = new BlockCapabilityDirectionalCache<>(); - } - - @Test - @SuppressWarnings({"unchecked", "rawtypes"}) - public void get() { - try (MockedStatic blockCapabilityCacheMockedStatic = mockStatic(BlockCapabilityCache.class)) { - BlockCapability blockCapability = mock(BlockCapability.class); - ServerLevel level = mock(ServerLevel.class); - BlockPos pos = mock(BlockPos.class); - Direction direction = mock(Direction.class); - - BlockCapabilityCache cache = mock(BlockCapabilityCache.class); - blockCapabilityCacheMockedStatic.when(() -> BlockCapabilityCache.create(blockCapability, level, pos, direction)).thenReturn(cache); - - Object check = mock(Object.class); - when(cache.getCapability()).thenReturn(check); - - assertSame(check, instance.get(blockCapability, level, pos, direction)); - // Once more to verify the caching - assertSame(check, instance.get(blockCapability, level, pos, direction)); - - blockCapabilityCacheMockedStatic.verify(() -> BlockCapabilityCache.create(blockCapability, level, pos, direction), times(1)); - } - } - -} diff --git a/src/test/java/twilightforest/util/WorldUtilTests.java b/src/test/java/twilightforest/util/WorldUtilTests.java new file mode 100644 index 0000000000..c68b3f2892 --- /dev/null +++ b/src/test/java/twilightforest/util/WorldUtilTests.java @@ -0,0 +1,122 @@ +package twilightforest.util; + +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerChunkCache; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.chunk.LevelChunk; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import tamaized.beanification.junit.MockitoFixer; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoFixer.class) +public class WorldUtilTests { + + @Test + public void filtersLoadedBlockEntitiesAcrossChunkBoundaries() { + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunkSource = mock(ServerChunkCache.class); + LevelChunk chunk00 = mock(LevelChunk.class); + LevelChunk chunk10 = mock(LevelChunk.class); + LevelChunk chunk01 = mock(LevelChunk.class); + + BlockEntity inside = blockEntityAt(14, 63, 14, false); + BlockEntity xEdge = blockEntityAt(16, 65, 14, false); + BlockEntity zEdge = blockEntityAt(14, 64, 16, false); + BlockEntity outsideHorizontal = blockEntityAt(13, 64, 15, false); + BlockEntity outsideVertical = blockEntityAt(15, 66, 15, false); + BlockEntity removed = blockEntityAt(15, 64, 15, true); + + when(level.getChunkSource()).thenReturn(chunkSource); + when(chunkSource.getChunkNow(0, 0)).thenReturn(chunk00); + when(chunkSource.getChunkNow(1, 0)).thenReturn(chunk10); + when(chunkSource.getChunkNow(0, 1)).thenReturn(chunk01); + when(chunkSource.getChunkNow(1, 1)).thenReturn(null); + when(chunk00.getBlockEntities()).thenReturn(Map.of( + new BlockPos(14, 63, 14), inside, + new BlockPos(13, 64, 15), outsideHorizontal, + new BlockPos(15, 66, 15), outsideVertical, + new BlockPos(15, 64, 15), removed + )); + when(chunk10.getBlockEntities()).thenReturn(Map.of(new BlockPos(16, 65, 14), xEdge)); + when(chunk01.getBlockEntities()).thenReturn(Map.of(new BlockPos(14, 64, 16), zEdge)); + + List result = WorldUtil.getLoadedBlockEntitiesInRange(level, new BlockPos(15, 64, 15), 1); + + assertEquals(3, result.size()); + assertEquals(Set.of(inside, xEdge, zEdge), new HashSet<>(result)); + verify(chunkSource).getChunkNow(0, 0); + verify(chunkSource).getChunkNow(1, 0); + verify(chunkSource).getChunkNow(0, 1); + verify(chunkSource).getChunkNow(1, 1); + verifyNoMoreInteractions(chunkSource); + } + + @Test + public void handlesNegativeChunkBoundaries() { + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunkSource = mock(ServerChunkCache.class); + LevelChunk chunkNegativeTwo = mock(LevelChunk.class); + LevelChunk chunkNegativeOne = mock(LevelChunk.class); + BlockEntity negativeEdge = blockEntityAt(-17, 64, -16, false); + BlockEntity positiveEdge = blockEntityAt(-15, 64, -16, false); + + when(level.getChunkSource()).thenReturn(chunkSource); + when(chunkSource.getChunkNow(-2, -2)).thenReturn(null); + when(chunkSource.getChunkNow(-1, -2)).thenReturn(null); + when(chunkSource.getChunkNow(-2, -1)).thenReturn(chunkNegativeTwo); + when(chunkSource.getChunkNow(-1, -1)).thenReturn(chunkNegativeOne); + when(chunkNegativeTwo.getBlockEntities()).thenReturn(Map.of(new BlockPos(-17, 64, -16), negativeEdge)); + when(chunkNegativeOne.getBlockEntities()).thenReturn(Map.of(new BlockPos(-15, 64, -16), positiveEdge)); + + List result = WorldUtil.getLoadedBlockEntitiesInRange(level, new BlockPos(-16, 64, -16), 1); + + assertEquals(2, result.size()); + assertEquals(Set.of(negativeEdge, positiveEdge), new HashSet<>(result)); + verify(chunkSource).getChunkNow(-2, -2); + verify(chunkSource).getChunkNow(-1, -2); + verify(chunkSource).getChunkNow(-2, -1); + verify(chunkSource).getChunkNow(-1, -1); + verifyNoMoreInteractions(chunkSource); + } + + @Test + public void supportsZeroRange() { + ServerLevel level = mock(ServerLevel.class); + ServerChunkCache chunkSource = mock(ServerChunkCache.class); + LevelChunk chunk = mock(LevelChunk.class); + BlockEntity center = blockEntityAt(1, 64, 1, false); + BlockEntity neighbor = blockEntityAt(2, 64, 1, false); + + when(level.getChunkSource()).thenReturn(chunkSource); + when(chunkSource.getChunkNow(0, 0)).thenReturn(chunk); + when(chunk.getBlockEntities()).thenReturn(Map.of( + new BlockPos(1, 64, 1), center, + new BlockPos(2, 64, 1), neighbor + )); + + List result = WorldUtil.getLoadedBlockEntitiesInRange(level, new BlockPos(1, 64, 1), 0); + + assertEquals(List.of(center), result); + verify(chunkSource).getChunkNow(0, 0); + verifyNoMoreInteractions(chunkSource); + } + + private static BlockEntity blockEntityAt(int x, int y, int z, boolean removed) { + BlockEntity blockEntity = mock(BlockEntity.class); + when(blockEntity.getBlockPos()).thenReturn(new BlockPos(x, y, z)); + when(blockEntity.isRemoved()).thenReturn(removed); + return blockEntity; + } +} From 7c0c4d867c1b199fbe39996cbcc9910d4c6dd8e7 Mon Sep 17 00:00:00 2001 From: FoxPack Date: Tue, 21 Jul 2026 09:19:58 +0300 Subject: [PATCH 2/3] Use BlockCapabilityCache for sorting tree lookups --- .../block/SortLogCoreBlock.java | 17 ++- .../events/SortingTreeEvents.java | 24 ++++ .../util/BlockCapabilityDirectionalCache.java | 57 ++++++++++ .../block/SortLogCoreBlockTests.java | 61 +++------- .../BlockCapabilityDirectionalCacheTests.java | 105 ++++++++++++++++++ 5 files changed, 215 insertions(+), 49 deletions(-) create mode 100644 src/main/java/twilightforest/events/SortingTreeEvents.java create mode 100644 src/main/java/twilightforest/util/BlockCapabilityDirectionalCache.java create mode 100644 src/test/java/twilightforest/util/BlockCapabilityDirectionalCacheTests.java diff --git a/src/main/java/twilightforest/block/SortLogCoreBlock.java b/src/main/java/twilightforest/block/SortLogCoreBlock.java index 5e18249e89..d1057e0032 100644 --- a/src/main/java/twilightforest/block/SortLogCoreBlock.java +++ b/src/main/java/twilightforest/block/SortLogCoreBlock.java @@ -14,10 +14,12 @@ import net.neoforged.neoforge.transfer.ResourceHandlerUtil; import net.neoforged.neoforge.transfer.item.ItemResource; import net.neoforged.neoforge.transfer.transaction.Transaction; +import org.jspecify.annotations.Nullable; import twilightforest.config.TFConfig; import twilightforest.init.TFParticleType; import twilightforest.network.ParticlePacket; import twilightforest.tags.TFEntityTypeTags; +import twilightforest.util.BlockCapabilityDirectionalCache; import twilightforest.util.WorldUtil; import java.util.ArrayList; @@ -27,6 +29,8 @@ public class SortLogCoreBlock extends SpecialMagicLogBlock { + private final BlockCapabilityDirectionalCache> capabilityCache = new BlockCapabilityDirectionalCache<>(Capabilities.Item.BLOCK); + public SortLogCoreBlock(Properties properties) { super(properties); } @@ -48,7 +52,7 @@ void performTreeEffect(ServerLevel level, BlockPos pos, RandomSource rand) { if (Math.abs(blockPos.getX() - pos.getX()) <= 2 && Math.abs(blockPos.getY() - pos.getY()) <= 2 && Math.abs(blockPos.getZ() - pos.getZ()) <= 2) { List> handlers = new ArrayList<>(); for (Direction side : Direction.values()) { - ResourceHandler handler = level.getCapability(Capabilities.Item.BLOCK, blockPos, blockEntity.getBlockState(), blockEntity, side); + ResourceHandler handler = this.getItemHandler(level, blockEntity, side); if (handler != null) handlers.add(handler); } if (!handlers.isEmpty()) { @@ -56,7 +60,7 @@ void performTreeEffect(ServerLevel level, BlockPos pos, RandomSource rand) { } } else { // Output if its outside that range for (Direction side : Direction.values()) { - ResourceHandler handler = level.getCapability(Capabilities.Item.BLOCK, blockPos, blockEntity.getBlockState(), blockEntity, side); + ResourceHandler handler = this.getItemHandler(level, blockEntity, side); if (handler != null) outputMap.put(handler, Vec3.upFromBottomCenterOf(blockPos, 1.9D)); } } @@ -135,4 +139,13 @@ void performTreeEffect(ServerLevel level, BlockPos pos, RandomSource rand) { } } } + + @Nullable + ResourceHandler getItemHandler(ServerLevel level, BlockEntity blockEntity, Direction side) { + return this.capabilityCache.get(level, blockEntity, side); + } + + public void clearCapabilityCache(ServerLevel level) { + this.capabilityCache.clear(level); + } } diff --git a/src/main/java/twilightforest/events/SortingTreeEvents.java b/src/main/java/twilightforest/events/SortingTreeEvents.java new file mode 100644 index 0000000000..fcf149b22e --- /dev/null +++ b/src/main/java/twilightforest/events/SortingTreeEvents.java @@ -0,0 +1,24 @@ +package twilightforest.events; + +import net.minecraft.server.level.ServerLevel; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.event.level.LevelEvent; +import tamaized.beanification.Component; +import tamaized.beanification.PostConstruct; +import twilightforest.block.SortLogCoreBlock; +import twilightforest.init.TFBlocks; + +@Component +public final class SortingTreeEvents { + + @PostConstruct + private void setup() { + NeoForge.EVENT_BUS.addListener(this::clearCapabilityCache); + } + + private void clearCapabilityCache(LevelEvent.Unload event) { + if (event.getLevel() instanceof ServerLevel level) { + ((SortLogCoreBlock) TFBlocks.SORTING_LOG_CORE.get()).clearCapabilityCache(level); + } + } +} diff --git a/src/main/java/twilightforest/util/BlockCapabilityDirectionalCache.java b/src/main/java/twilightforest/util/BlockCapabilityDirectionalCache.java new file mode 100644 index 0000000000..a59bb31e0e --- /dev/null +++ b/src/main/java/twilightforest/util/BlockCapabilityDirectionalCache.java @@ -0,0 +1,57 @@ +package twilightforest.util; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.neoforged.neoforge.capabilities.BlockCapability; +import net.neoforged.neoforge.capabilities.BlockCapabilityCache; +import org.jspecify.annotations.Nullable; + +import java.util.EnumMap; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Caches sided block capabilities for loaded block entities, separated by level. + * + *

Entries within a level are weakly keyed by the target block entity rather than globally keyed by position. + * This keeps identical positions in different levels separate and allows the directional caches to be collected + * after NeoForge invalidates them on a normal block or chunk unload.

+ * + *

{@link BlockCapabilityCache} retains its {@link ServerLevel}, and level unload does not fire the usual chunk + * invalidations. Call {@link #clear(ServerLevel)} from {@code LevelEvent.Unload} to release the level explicitly.

+ */ +public final class BlockCapabilityDirectionalCache { + + private final BlockCapability capability; + private final Map>>> data = new IdentityHashMap<>(); + + public BlockCapabilityDirectionalCache(BlockCapability capability) { + this.capability = capability; + } + + @Nullable + public R get(ServerLevel level, BlockEntity blockEntity, Direction direction) { + Map>> levelCaches = this.data.computeIfAbsent( + level, + ignored -> new WeakHashMap<>() + ); + EnumMap> directionalCaches = levelCaches.computeIfAbsent( + blockEntity, + ignored -> new EnumMap<>(Direction.class) + ); + BlockPos blockPos = blockEntity.getBlockPos(); + BlockCapabilityCache cache = directionalCaches.get(direction); + if (cache == null || cache.level() != level || !cache.pos().equals(blockPos)) { + cache = BlockCapabilityCache.create(this.capability, level, blockPos, direction); + directionalCaches.put(direction, cache); + } + return cache.getCapability(); + } + + public void clear(ServerLevel level) { + this.data.remove(level); + } +} diff --git a/src/test/java/twilightforest/block/SortLogCoreBlockTests.java b/src/test/java/twilightforest/block/SortLogCoreBlockTests.java index 0c30d62af6..2d5fff3d3a 100644 --- a/src/test/java/twilightforest/block/SortLogCoreBlockTests.java +++ b/src/test/java/twilightforest/block/SortLogCoreBlockTests.java @@ -8,10 +8,8 @@ import net.minecraft.util.RandomSource; import net.minecraft.world.entity.Entity; import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.LevelChunk; import net.minecraft.world.phys.AABB; -import net.neoforged.neoforge.capabilities.Capabilities; import net.neoforged.neoforge.network.PacketDistributor; import net.neoforged.neoforge.transfer.ResourceStacksResourceHandler; import net.neoforged.neoforge.transfer.item.ItemResource; @@ -31,9 +29,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.times; @@ -52,28 +50,20 @@ public void transfersItemsAcrossAChunkBoundary() { TreeTestFixture fixture = treeTestFixture(64); try (MockedStatic packets = mockStatic(PacketDistributor.class)) { - runTreeEffect(fixture); + SortLogCoreBlock block = runTreeEffect(fixture); packets.verify(() -> PacketDistributor.sendToPlayersNear( same(fixture.level()), isNull(), anyDouble(), anyDouble(), anyDouble(), anyDouble(), any(ParticlePacket.class) )); + verify(block, times(Direction.values().length)).getItemHandler( + same(fixture.level()), same(fixture.inputBlockEntity()), any(Direction.class) + ); + verify(block, times(Direction.values().length)).getItemHandler( + same(fixture.level()), same(fixture.outputBlockEntity()), any(Direction.class) + ); } assertEquals(1, fixture.input().getAmountAsInt(0)); assertEquals(2, fixture.output().getAmountAsInt(0)); - verify(fixture.level(), times(Direction.values().length)).getCapability( - eq(Capabilities.Item.BLOCK), - eq(fixture.inputPos()), - same(fixture.inputState()), - same(fixture.inputBlockEntity()), - any(Direction.class) - ); - verify(fixture.level(), times(Direction.values().length)).getCapability( - eq(Capabilities.Item.BLOCK), - eq(fixture.outputPos()), - same(fixture.outputState()), - same(fixture.outputBlockEntity()), - any(Direction.class) - ); } finally { TFConfig.sortingCoreRange = originalRange; } @@ -109,8 +99,6 @@ private static TreeTestFixture treeTestFixture(int outputCapacity) { BlockPos outputPos = new BlockPos(11, 64, 0); BlockEntity inputBlockEntity = blockEntityAt(inputPos); BlockEntity outputBlockEntity = blockEntityAt(outputPos); - BlockState inputState = inputBlockEntity.getBlockState(); - BlockState outputState = outputBlockEntity.getBlockState(); ItemResource empty = mock(ItemResource.class); ItemResource diamonds = mock(ItemResource.class); when(empty.isEmpty()).thenReturn(true); @@ -125,20 +113,6 @@ private static TreeTestFixture treeTestFixture(int outputCapacity) { when(chunkSource.getChunkNow(1, 0)).thenReturn(inputChunk); when(inputChunk.getBlockEntities()).thenReturn(Map.of(inputPos, inputBlockEntity)); when(outputChunk.getBlockEntities()).thenReturn(Map.of(outputPos, outputBlockEntity)); - when(level.getCapability( - eq(Capabilities.Item.BLOCK), - eq(inputPos), - same(inputState), - same(inputBlockEntity), - any(Direction.class) - )).thenReturn(input); - when(level.getCapability( - eq(Capabilities.Item.BLOCK), - eq(outputPos), - same(outputState), - same(outputBlockEntity), - any(Direction.class) - )).thenReturn(output); when(level.getEntities( isNull(Entity.class), any(AABB.class), @@ -148,28 +122,25 @@ private static TreeTestFixture treeTestFixture(int outputCapacity) { return new TreeTestFixture( level, corePos, - inputPos, - outputPos, inputBlockEntity, outputBlockEntity, - inputState, - outputState, input, output ); } - private static void runTreeEffect(TreeTestFixture fixture) { + private static SortLogCoreBlock runTreeEffect(TreeTestFixture fixture) { // Constructing an unregistered block after Minecraft freezes its registries is not allowed in unit tests. - mock(SortLogCoreBlock.class, Answers.CALLS_REAL_METHODS) - .performTreeEffect(fixture.level(), fixture.corePos(), RandomSource.create(0L)); + SortLogCoreBlock block = mock(SortLogCoreBlock.class, Answers.CALLS_REAL_METHODS); + doAnswer(invocation -> invocation.getArgument(1) == fixture.inputBlockEntity() ? fixture.input() : fixture.output()) + .when(block).getItemHandler(same(fixture.level()), any(BlockEntity.class), any(Direction.class)); + block.performTreeEffect(fixture.level(), fixture.corePos(), RandomSource.create(0L)); + return block; } private static BlockEntity blockEntityAt(BlockPos pos) { BlockEntity blockEntity = mock(BlockEntity.class); - BlockState state = mock(BlockState.class); when(blockEntity.getBlockPos()).thenReturn(pos); - when(blockEntity.getBlockState()).thenReturn(state); when(blockEntity.isRemoved()).thenReturn(false); return blockEntity; } @@ -192,12 +163,8 @@ protected int getCapacity(int index, ItemResource resource) { private record TreeTestFixture( ServerLevel level, BlockPos corePos, - BlockPos inputPos, - BlockPos outputPos, BlockEntity inputBlockEntity, BlockEntity outputBlockEntity, - BlockState inputState, - BlockState outputState, TestItemHandler input, TestItemHandler output ) { diff --git a/src/test/java/twilightforest/util/BlockCapabilityDirectionalCacheTests.java b/src/test/java/twilightforest/util/BlockCapabilityDirectionalCacheTests.java new file mode 100644 index 0000000000..8e53a61e73 --- /dev/null +++ b/src/test/java/twilightforest/util/BlockCapabilityDirectionalCacheTests.java @@ -0,0 +1,105 @@ +package twilightforest.util; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.neoforged.neoforge.capabilities.BlockCapability; +import net.neoforged.neoforge.capabilities.BlockCapabilityCache; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import tamaized.beanification.junit.MockitoFixer; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoFixer.class) +public class BlockCapabilityDirectionalCacheTests { + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void reusesCacheForTheSameSideAndSeparatesDirections() { + BlockCapability capability = mock(BlockCapability.class); + BlockCapabilityDirectionalCache instance = new BlockCapabilityDirectionalCache<>(capability); + ServerLevel level = mock(ServerLevel.class); + BlockEntity blockEntity = blockEntityAt(new BlockPos(4, 8, 15)); + BlockCapabilityCache northCache = mock(BlockCapabilityCache.class); + BlockCapabilityCache southCache = mock(BlockCapabilityCache.class); + Object northHandler = new Object(); + Object southHandler = new Object(); + + try (MockedStatic caches = mockStatic(BlockCapabilityCache.class)) { + caches.when(() -> BlockCapabilityCache.create(capability, level, blockEntity.getBlockPos(), Direction.NORTH)).thenReturn(northCache); + caches.when(() -> BlockCapabilityCache.create(capability, level, blockEntity.getBlockPos(), Direction.SOUTH)).thenReturn(southCache); + when(northCache.level()).thenReturn(level); + when(northCache.pos()).thenReturn(blockEntity.getBlockPos()); + when(northCache.getCapability()).thenReturn(northHandler); + when(southCache.getCapability()).thenReturn(southHandler); + + assertSame(northHandler, instance.get(level, blockEntity, Direction.NORTH)); + assertSame(northHandler, instance.get(level, blockEntity, Direction.NORTH)); + assertSame(southHandler, instance.get(level, blockEntity, Direction.SOUTH)); + + caches.verify(() -> BlockCapabilityCache.create(capability, level, blockEntity.getBlockPos(), Direction.NORTH), times(1)); + caches.verify(() -> BlockCapabilityCache.create(capability, level, blockEntity.getBlockPos(), Direction.SOUTH), times(1)); + verify(northCache, times(2)).getCapability(); + verify(southCache).getCapability(); + } + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void keepsLevelsIndependentAndClearsOnlyTheUnloadedLevel() { + BlockCapability capability = mock(BlockCapability.class); + BlockCapabilityDirectionalCache instance = new BlockCapabilityDirectionalCache<>(capability); + ServerLevel firstLevel = mock(ServerLevel.class); + ServerLevel secondLevel = mock(ServerLevel.class); + BlockPos sharedPos = new BlockPos(4, 8, 15); + BlockEntity firstBlockEntity = blockEntityAt(sharedPos); + BlockEntity secondBlockEntity = blockEntityAt(sharedPos); + BlockCapabilityCache firstCache = mock(BlockCapabilityCache.class); + BlockCapabilityCache secondCache = mock(BlockCapabilityCache.class); + BlockCapabilityCache recreatedFirstCache = mock(BlockCapabilityCache.class); + Object firstHandler = new Object(); + Object secondHandler = new Object(); + Object recreatedFirstHandler = new Object(); + + try (MockedStatic caches = mockStatic(BlockCapabilityCache.class)) { + caches.when(() -> BlockCapabilityCache.create(capability, firstLevel, sharedPos, Direction.NORTH)).thenReturn(firstCache, recreatedFirstCache); + caches.when(() -> BlockCapabilityCache.create(capability, secondLevel, sharedPos, Direction.NORTH)).thenReturn(secondCache); + when(firstCache.level()).thenReturn(firstLevel); + when(firstCache.pos()).thenReturn(sharedPos); + when(secondCache.level()).thenReturn(secondLevel); + when(secondCache.pos()).thenReturn(sharedPos); + when(recreatedFirstCache.level()).thenReturn(firstLevel); + when(recreatedFirstCache.pos()).thenReturn(sharedPos); + when(firstCache.getCapability()).thenReturn(firstHandler); + when(secondCache.getCapability()).thenReturn(secondHandler); + when(recreatedFirstCache.getCapability()).thenReturn(recreatedFirstHandler); + + assertSame(firstHandler, instance.get(firstLevel, firstBlockEntity, Direction.NORTH)); + assertSame(secondHandler, instance.get(secondLevel, secondBlockEntity, Direction.NORTH)); + + instance.clear(firstLevel); + + assertSame(recreatedFirstHandler, instance.get(firstLevel, firstBlockEntity, Direction.NORTH)); + assertSame(secondHandler, instance.get(secondLevel, secondBlockEntity, Direction.NORTH)); + + caches.verify(() -> BlockCapabilityCache.create(capability, firstLevel, sharedPos, Direction.NORTH), times(2)); + caches.verify(() -> BlockCapabilityCache.create(capability, secondLevel, sharedPos, Direction.NORTH), times(1)); + verify(secondCache, times(2)).getCapability(); + } + } + + private static BlockEntity blockEntityAt(BlockPos pos) { + BlockEntity blockEntity = mock(BlockEntity.class); + when(blockEntity.getBlockPos()).thenReturn(pos); + return blockEntity; + } +} From 73e506b0fd28e3f023d9f846e4cbb3a1c5358218 Mon Sep 17 00:00:00 2001 From: FoxPack Date: Wed, 22 Jul 2026 11:55:20 +0300 Subject: [PATCH 3/3] Move sorting tree cleanup into MiscEvents --- .../twilightforest/events/MiscEvents.java | 9 +++++++ .../events/SortingTreeEvents.java | 24 ------------------- 2 files changed, 9 insertions(+), 24 deletions(-) delete mode 100644 src/main/java/twilightforest/events/SortingTreeEvents.java diff --git a/src/main/java/twilightforest/events/MiscEvents.java b/src/main/java/twilightforest/events/MiscEvents.java index 224fe77dd3..86c3513b33 100644 --- a/src/main/java/twilightforest/events/MiscEvents.java +++ b/src/main/java/twilightforest/events/MiscEvents.java @@ -24,9 +24,11 @@ import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent; import net.neoforged.neoforge.event.entity.living.LivingEquipmentChangeEvent; import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; +import net.neoforged.neoforge.event.level.LevelEvent; import net.neoforged.neoforge.network.PacketDistributor; import tamaized.beanification.Component; import tamaized.beanification.PostConstruct; +import twilightforest.block.SortLogCoreBlock; import twilightforest.compat.curios.CuriosCompat; import twilightforest.entity.monster.DeathTome; import twilightforest.entity.passive.Bighorn; @@ -47,6 +49,13 @@ private void setup() { NeoForge.EVENT_BUS.addListener(this::updateCicadaSoundsOnHead); NeoForge.EVENT_BUS.addListener(this::addTomesToLecterns); NeoForge.EVENT_BUS.addListener(this::washOffCloth); + NeoForge.EVENT_BUS.addListener(this::clearSortingTreeCapabilityCache); + } + + private void clearSortingTreeCapabilityCache(LevelEvent.Unload event) { + if (event.getLevel() instanceof ServerLevel level) { + ((SortLogCoreBlock) TFBlocks.SORTING_LOG_CORE.get()).clearCapabilityCache(level); + } } private void addPrey(EntityJoinLevelEvent event) { diff --git a/src/main/java/twilightforest/events/SortingTreeEvents.java b/src/main/java/twilightforest/events/SortingTreeEvents.java deleted file mode 100644 index fcf149b22e..0000000000 --- a/src/main/java/twilightforest/events/SortingTreeEvents.java +++ /dev/null @@ -1,24 +0,0 @@ -package twilightforest.events; - -import net.minecraft.server.level.ServerLevel; -import net.neoforged.neoforge.common.NeoForge; -import net.neoforged.neoforge.event.level.LevelEvent; -import tamaized.beanification.Component; -import tamaized.beanification.PostConstruct; -import twilightforest.block.SortLogCoreBlock; -import twilightforest.init.TFBlocks; - -@Component -public final class SortingTreeEvents { - - @PostConstruct - private void setup() { - NeoForge.EVENT_BUS.addListener(this::clearCapabilityCache); - } - - private void clearCapabilityCache(LevelEvent.Unload event) { - if (event.getLevel() instanceof ServerLevel level) { - ((SortLogCoreBlock) TFBlocks.SORTING_LOG_CORE.get()).clearCapabilityCache(level); - } - } -}