From bc89822785a6b86879aa43565c9c5d2a9f2b39ef Mon Sep 17 00:00:00 2001 From: aihaoDIYlove Date: Sat, 5 Sep 2026 09:32:05 +0800 Subject: [PATCH 1/3] fix: don't drop blocks when demolishing an invalidated plot Mass invalidation (MassTracker.isInvalid) can only trigger while the plot contains air and zero-mass blocks - any block with mass would keep the tracker valid. destroyBlock(pos, true) therefore only ever dropped weightless decorations, which the disassembly sweep has already moved back to the world: a clean item duplication. Storage blocks with empty collision shapes are the worst case - a shelf or rack holding a loaded shulker box duplicates the box together with all of its contents on every disassembly, even though the shulker itself has mass and never triggers the invalidation. Pass dropItems=false at the invalidation call sites and remove blocks by detaching their block entity and replacing them with the fluid's legacy state: this also suppresses the destroy level event and neighbor updates - nothing needs to react to a wholesale plot deletion, and updates could pop foreign blocks living in adjacent plot grid cells - and prevents container onRemove hooks from spilling contents. The heat-split path keeps dropping, as no sweep races it there. --- .../sable/api/sublevel/SubLevelContainer.java | 2 +- .../sable/sublevel/plot/ServerLevelPlot.java | 21 +++++++++++++++++-- .../plot/heat/SubLevelHeatMapManager.java | 2 +- .../system/SubLevelPhysicsSystem.java | 2 +- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java index e1a56349..7f838ec7 100644 --- a/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java +++ b/common/src/main/java/dev/ryanhcode/sable/api/sublevel/SubLevelContainer.java @@ -154,7 +154,7 @@ public void processSubLevelRemovals() { for (final SubLevel subLevel : this.allSubLevels) { if (subLevel instanceof final ServerSubLevel serverSubLevel) { if (!serverSubLevel.isRemoved() && serverSubLevel.getMassTracker().isInvalid()) { - serverSubLevel.getPlot().destroyAllBlocks(); + serverSubLevel.getPlot().destroyAllBlocks(false); serverSubLevel.markRemoved(); } } diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java index f0e57705..b1a84348 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/ServerLevelPlot.java @@ -291,8 +291,12 @@ public void kickAllEntities() { /** * Destroys all blocks within the plot + * + * @param dropItems whether the destroyed blocks should drop their items. Mass invalidation must pass + * {@code false}: at that point the plot only ever contains air and zero-mass blocks, which the + * disassembly sweep has already moved back to the world. */ - public void destroyAllBlocks() { + public void destroyAllBlocks(boolean dropItems) { if (this.localBounds == null || this.localBounds == BoundingBox3i.EMPTY) { return; } @@ -305,7 +309,20 @@ public void destroyAllBlocks() { for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) { final BlockPos pos = new BlockPos(x, y, z); - level.destroyBlock(pos, true); + if (dropItems) { + level.destroyBlock(pos, true); + } else { + final BlockState state = level.getBlockState(pos); + if (state.isAir()) { + continue; + } + // Detach the block entity first so onRemove(...) cannot spill container contents. Neighbor + // updates are suppressed as well: nothing needs to react to a wholesale plot deletion, and + // updates can pop foreign blocks living in adjacent plot grid cells (dropping them). + level.removeBlockEntity(pos); + level.setBlock(pos, state.getFluidState().createLegacyBlock(), + Block.UPDATE_CLIENTS | Block.UPDATE_MOVE_BY_PISTON); + } } } } diff --git a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/heat/SubLevelHeatMapManager.java b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/heat/SubLevelHeatMapManager.java index 6816fda1..38e787ed 100644 --- a/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/heat/SubLevelHeatMapManager.java +++ b/common/src/main/java/dev/ryanhcode/sable/sublevel/plot/heat/SubLevelHeatMapManager.java @@ -243,7 +243,7 @@ private void split() { // Protect against split sub-levels that have zero mass. if (subLevel.getSelfMassTracker().getCenterOfMass() == null || subLevel.getSelfMassTracker().getMass() <= 0.0) { - subLevel.getPlot().destroyAllBlocks(); + subLevel.getPlot().destroyAllBlocks(true); final SubLevelContainer container = Objects.requireNonNull(SubLevelContainer.getContainer(level)); container.removeSubLevel(subLevel, SubLevelRemovalReason.REMOVED); 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..c742ea46 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 @@ -531,7 +531,7 @@ public void updateMassDataFromBlockChange(final SubLevel subLevel, final BlockPo if (oldMass != 0.0) massTracker.addBlockMass(level, oldState, globalBlockPos, -oldMass, oldInertia); if (!subLevel.isRemoved() && massTracker.isInvalid()) { - serverSubLevel.getPlot().destroyAllBlocks(); + serverSubLevel.getPlot().destroyAllBlocks(false); serverSubLevel.markRemoved(); return; } From 6ef4a6d4214936134b80995fe0f2f24a7e5ddefb Mon Sep 17 00:00:00 2001 From: aihaoDIYlove Date: Sat, 5 Sep 2026 09:32:06 +0800 Subject: [PATCH 2/3] fix: detach non-clearable block entities when moving them into a sub-level moveBlocks snapshots the source BE and relies on Clearable.tryClear to empty it before the source is destroyed. Block entities that do not implement Clearable keep their contents on the source position, and the unconditional onRemove(...) hook then drops a duplicate of everything the destination copy already received via the NBT snapshot (on disassembly the duplicate is voided inside the dying sub-level instead). Detach the source block entity - the same escape hatch as #sable:silent_assembly_removal, applied automatically. Every vanilla item-holding BE implements Clearable via Container, so this only affects modded blocks. --- .../java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java b/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java index 83ac4711..38373185 100644 --- a/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java +++ b/common/src/main/java/dev/ryanhcode/sable/api/SubLevelAssemblyHelper.java @@ -402,6 +402,11 @@ public static void moveBlocks(final ServerLevel level, final AssemblyTransform t container.setLootTable(null); } Clearable.tryClear(blockEntity); + if (blockEntity != null && !(blockEntity instanceof Clearable)) { + // The destination copy already owns the contents; leaving them in the source block entity + // would make the destruction below drop (or void) a duplicate via onRemove(...). + level.removeBlockEntity(block); + } } final LevelChunk chunk = resultingAccelerator.getChunk(SectionPos.blockToSectionCoord(newPos.getX()), SectionPos.blockToSectionCoord(newPos.getZ())); From f1c01892d62a69d08491248684af1b368b49f58e Mon Sep 17 00:00:00 2001 From: aihaoDIYlove Date: Sat, 5 Sep 2026 09:32:06 +0800 Subject: [PATCH 3/3] test: cover massless plot demolition and ignore IDE bin/ output testMasslessInvalidationDoesNotDrop: assemble a stone block with a zero-mass wall torch on each side, remove the stone so the mass tracker invalidates through the real block-change path, and assert that no torch items drop anywhere in the level when the plot is demolished. The drops land at the plot's position in the level, far away from the test structure, and only torches this test placed count as evidence - the shared plot grid can pop foreign decorations from neighbouring grid content during assembly. Fails on main with four torch items, passes with the previous commits. Non-clearable block entities cannot be covered with vanilla blocks (all of them implement Clearable via Container). bin/ is the default output directory of the VS Code Java language server and Eclipse, mirroring the existing out/ ignore for IntelliJ. --- .gitignore | 3 + .../sable/neoforge/gametest/AssemblyTest.java | 78 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/.gitignore b/.gitignore index 75c6db86..13a01769 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ # IntelliJ out/ + +# Eclipse / VS Code Java language server build output +bin/ # mpeltonen/sbt-idea plugin .idea_modules/ diff --git a/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/AssemblyTest.java b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/AssemblyTest.java index 5933724c..f6e84112 100644 --- a/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/AssemblyTest.java +++ b/neoforge/src/main/java/dev/ryanhcode/sable/neoforge/gametest/AssemblyTest.java @@ -24,11 +24,14 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.item.ItemEntity; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.WallTorchBlock; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.BlockStateProperties; import net.minecraft.world.level.block.state.properties.Property; @@ -118,6 +121,81 @@ public static void testBrittleBreaking(final GameTestHelper helper) { }); } + /** + * Regression test: demolishing an invalidated (massless) plot must not drop anything — the weightless blocks + * left in the plot are duplicates of blocks the disassembly sweep has already moved back to the world. + */ + @GameTest(template = "brittlebreak") + public static void testMasslessInvalidationDoesNotDrop(final GameTestHelper helper) { + final ServerLevel level = helper.getLevel(); + final ServerSubLevelContainer plotContainer = SubLevelContainer.getContainer(level); + if (plotContainer == null) { + throw new IllegalStateException("Plot container not found in level"); + } + + final BlockPos min = helper.absolutePos(new BlockPos(0, 1, 0)); + final BlockPos max = helper.absolutePos(new BlockPos(2, 3, 2)); + final BoundingBox3i bounds = new BoundingBox3i( + min.getX(), min.getY(), min.getZ(), + max.getX(), max.getY(), max.getZ() + ); + + // A single stone with a zero-mass wall torch on each of its four sides (covering every facing). + final BlockPos stonePos = new BlockPos(1, 1, 1); + + // Reset the template area — the floor layer included, or stray template decorations can drop during the + // run — so the test does not depend on template contents. Shape updates are suppressed (flag 16): clearing + // with normal flags would pop the template's adjacent redstone components and drop their items. + for (final BlockPos pos : BlockPos.betweenClosed(new BlockPos(0, 0, 0), new BlockPos(2, 3, 2))) { + level.setBlock(helper.absolutePos(pos), Blocks.AIR.defaultBlockState(), + Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE); + } + helper.setBlock(stonePos, Blocks.STONE.defaultBlockState()); + for (final Direction direction : Direction.Plane.HORIZONTAL) { + helper.setBlock(stonePos.relative(direction), + Blocks.WALL_TORCH.defaultBlockState().setValue(WallTorchBlock.FACING, direction)); + } + + final ServerSubLevel subLevel = SubLevelAssemblyHelper.assembleBlocks(level, min, BlockPos.betweenClosed(min, max), bounds); + + helper.runAtTickTime(10, () -> { + final Level plot = subLevel.getLevel(); + final BoundingBox3ic plotBounds = subLevel.getPlot().getBoundingBox(); + + // Remove the only mass-bearing block. Shape updates are suppressed (flag 16), matching silent + // disassembly sweeps, so the zero-mass torches are not popped first. This invalidates the mass + // tracker, and the physics system demolishes the plot through the real invalidation path. + for (final BlockPos pos : BlockPos.betweenClosed(plotBounds.minX(), plotBounds.minY(), plotBounds.minZ(), + plotBounds.maxX(), plotBounds.maxY(), plotBounds.maxZ())) { + if (plot.getBlockState(pos).isAir() || plot.getBlockState(pos).getBlock() == Blocks.WALL_TORCH) { + continue; + } + plot.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE); + } + + // Fallback demolition in case the mass update hook did not fire; a no-op if the plot is already gone. + // Demolishing spawns the drops at the plot's position in the level, far away from the test structure, + // so the assertion has to cover the whole level. The template reset above keeps it free of stray items. + helper.runAtTickTime(20, () -> { + if (!subLevel.isRemoved()) { + subLevel.getPlot().destroyAllBlocks(false); + } + + helper.runAtTickTime(30, () -> { + // Only the torches this test placed count as evidence: the shared plot grid can pop foreign + // decorations from neighbouring grid content during assembly, which drops unrelated items. + final List droppedTorches = level.getEntities(EntityType.ITEM, + itemEntity -> itemEntity.getItem().is(Blocks.WALL_TORCH.asItem())); + if (!droppedTorches.isEmpty()) { + helper.fail("Demolishing the invalidated plot dropped " + droppedTorches.size() + + " zero-mass wall torch item(s)"); + } + helper.succeed(); + }); + }); + }); + } + @GameTest(template = "allblocks", required = false, manualOnly = true, timeoutTicks = 30_000_000) public static void testAllBlocks(final GameTestHelper helper) { final boolean failOnFirstError = false;