From d0a7b7e0f72149b908a1cd7e3d1cf5f4a0e55daf Mon Sep 17 00:00:00 2001 From: Colin Vaughn Date: Wed, 26 Aug 2026 12:45:11 -0400 Subject: [PATCH] Fix unloaded chunk reads during parallel ticking --- .../system/SubLevelPhysicsSystem.java | 2 +- .../sable/util/LevelAccelerator.java | 25 ++++++++------ .../sable/neoforge/gametest/PhysicsTest.java | 33 +++++++++++++++++++ .../impl/rapier/RapierPhysicsPipeline.java | 2 +- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/system/SubLevelPhysicsSystem.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/system/SubLevelPhysicsSystem.java index d4e68fee..b22dad6c 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/system/SubLevelPhysicsSystem.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/system/SubLevelPhysicsSystem.java @@ -456,7 +456,7 @@ public RigidBodyHandle getPhysicsHandle(@NotNull final ServerSubLevel subLevel) * @param oldState the old block state * @param newState the new block state */ - public void handleBlockChange(final SectionPos sectionPos, final LevelChunkSection section, final int localX, final int localY, final int localZ, final BlockState oldState, final BlockState newState) { + public synchronized void handleBlockChange(final SectionPos sectionPos, final LevelChunkSection section, final int localX, final int localY, final int localZ, final BlockState oldState, final BlockState newState) { final ChunkPos chunk = sectionPos.chunk(); final LevelPlot plot = ((SubLevelContainerHolder) this.level).sable$getPlotContainer().getPlot(chunk); if (plot != null) { diff --git a/common/src/main/java/dev/ryanhcode/sable/util/LevelAccelerator.java b/common/src/main/java/dev/ryanhcode/sable/util/LevelAccelerator.java index 358f4027..191e03f5 100644 --- a/common/src/main/java/dev/ryanhcode/sable/util/LevelAccelerator.java +++ b/common/src/main/java/dev/ryanhcode/sable/util/LevelAccelerator.java @@ -16,7 +16,7 @@ import net.minecraft.world.level.chunk.LevelChunk; import net.minecraft.world.level.chunk.LevelChunkSection; import net.minecraft.world.level.material.FluidState; -import org.jetbrains.annotations.NotNull; +import net.minecraft.world.level.material.Fluids; import org.jetbrains.annotations.Nullable; /** @@ -40,7 +40,7 @@ public LevelAccelerator(final Level level) { this.minSection = level.getMinSection(); } - public void clearCache() { + public synchronized void clearCache() { this.cachedLevelChunks.clear(); this.cachedChunkObj = null; this.cachedChunkPos = 0L; @@ -58,13 +58,14 @@ public void setBlockFast(final BlockPos blockPos, final BlockState blockState) { @Override public @Nullable BlockEntity getBlockEntity(final BlockPos blockPos) { - return this.level.getBlockEntity(blockPos); + final LevelChunk chunk = this.getChunkIfLoaded(blockPos.getX() >> 4, blockPos.getZ() >> 4); + return chunk == null ? null : chunk.getBlockEntity(blockPos); } @Override public BlockState getBlockState(final BlockPos pos) { - final LevelChunk chunk = this.getChunk(pos); - return this.getBlockState(chunk, pos); + final LevelChunk chunk = this.getChunkIfLoaded(pos.getX() >> 4, pos.getZ() >> 4); + return chunk == null ? Blocks.AIR.defaultBlockState() : this.getBlockState(chunk, pos); } /** @@ -85,9 +86,8 @@ public BlockState getBlockState(final LevelChunk chunk, final BlockPos pos) { @Override public FluidState getFluidState(final BlockPos pos) { - final LevelChunk chunk = this.getChunk(pos); - - return chunk.getFluidState(pos); + final LevelChunk chunk = this.getChunkIfLoaded(pos.getX() >> 4, pos.getZ() >> 4); + return chunk == null ? Fluids.EMPTY.defaultFluidState() : chunk.getFluidState(pos); } public LevelChunk getChunk(final BlockPos pos) { @@ -95,6 +95,11 @@ public LevelChunk getChunk(final BlockPos pos) { } public LevelChunk getChunk(final int chunkX, final int chunkZ) { + final LevelChunk chunk = this.getChunkIfLoaded(chunkX, chunkZ); + return chunk == null ? this.level.getChunk(chunkX, chunkZ) : chunk; + } + + private synchronized @Nullable LevelChunk getChunkIfLoaded(final int chunkX, final int chunkZ) { final long pos = ChunkPos.asLong(chunkX, chunkZ); if (pos == this.cachedChunkPos && this.cachedChunkObj != null) { @@ -115,7 +120,7 @@ public LevelChunk getChunk(final int chunkX, final int chunkZ) { return chunk; } - private @NotNull LevelChunk grabChunkFast(final int chunkX, final int chunkZ, final long pos) { + private @Nullable LevelChunk grabChunkFast(final int chunkX, final int chunkZ, final long pos) { if (this.level.isClientSide) { return this.level.getChunk(chunkX, chunkZ); } @@ -129,7 +134,7 @@ public LevelChunk getChunk(final int chunkX, final int chunkZ) { return res; } - return this.level.getChunk(chunkX, chunkZ); + return null; } public boolean isOutsideBuildHeight(final Vec3i pos) { diff --git a/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/PhysicsTest.java b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/PhysicsTest.java index 3b5a3f6a..3dd3b08c 100644 --- a/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/PhysicsTest.java +++ b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/PhysicsTest.java @@ -7,6 +7,7 @@ import dev.ryanhcode.sable.physics.config.dimension_physics.DimensionPhysicsData; import dev.ryanhcode.sable.sublevel.ServerSubLevel; import dev.ryanhcode.sable.sublevel.system.SubLevelPhysicsSystem; +import dev.ryanhcode.sable.util.LevelAccelerator; import net.minecraft.core.BlockPos; import net.minecraft.gametest.framework.GameTest; import net.minecraft.gametest.framework.GameTestAssertPosException; @@ -17,11 +18,43 @@ import org.joml.Vector3d; import org.joml.Vector3dc; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + import static dev.ryanhcode.sable.neoforge.gametest.SableTestHelper.*; @GameTestHolder(Sable.MOD_ID) public final class PhysicsTest { + @GameTest(template = "continuouscollision", timeoutTicks = 100) + public static void testUnloadedChunkReadsOffThread(final GameTestHelper helper) { + final ServerLevel level = helper.getLevel(); + final BlockPos unloaded = helper.absolutePos(new BlockPos(16_384, 0, 16_384)); + if (level.hasChunkAt(unloaded)) { + helper.fail("Test chunk was already loaded"); + return; + } + + CompletableFuture.supplyAsync(() -> { + final LevelAccelerator accelerator = new LevelAccelerator(level); + return accelerator.getBlockState(unloaded).isAir() + && accelerator.getFluidState(unloaded).isEmpty() + && accelerator.getBlockEntity(unloaded) == null; + }) + .orTimeout(2, TimeUnit.SECONDS) + .whenComplete((safe, failure) -> level.getServer().execute(() -> { + if (failure != null) { + helper.fail("Off-thread unloaded chunk read blocked or failed: " + failure); + } else if (level.hasChunkAt(unloaded)) { + helper.fail("Off-thread read loaded the chunk"); + } else if (!safe) { + helper.fail("Unloaded chunk did not read as empty"); + } else { + helper.succeed(); + } + })); + } + @GameTest(template = "continuouscollision") public static void testContinuousCollision(final GameTestHelper helper) { final ServerLevel level = helper.getLevel(); diff --git a/sable_rapier/src/main/java/dev/ryanhcode/sable/physics/impl/rapier/RapierPhysicsPipeline.java b/sable_rapier/src/main/java/dev/ryanhcode/sable/physics/impl/rapier/RapierPhysicsPipeline.java index f741a1a1..f244464e 100644 --- a/sable_rapier/src/main/java/dev/ryanhcode/sable/physics/impl/rapier/RapierPhysicsPipeline.java +++ b/sable_rapier/src/main/java/dev/ryanhcode/sable/physics/impl/rapier/RapierPhysicsPipeline.java @@ -392,7 +392,7 @@ public void handleBlockChange(final SectionPos sectionPos, final LevelChunkSecti for (final Direction dir : Direction.values()) { final BlockPos pos = globalBlockPos.relative(dir); final VoxelNeighborhoodState state = VoxelNeighborhoodState.getState(this.accelerator, pos, null); - final RapierVoxelColliderData colliderData = this.colliderBakery.getPhysicsDataForBlock(this.level.getBlockState(pos)); + final RapierVoxelColliderData colliderData = this.colliderBakery.getPhysicsDataForBlock(this.accelerator.getBlockState(pos)); final int colliderValue = colliderData == null ? 0 : colliderData.handle() + 1; Rapier3D.changeBlock(this.scene.handle(), pos.getX(), pos.getY(), pos.getZ(), packBlockState(state, colliderValue));