From 54d11934033754dbd7b83665c852c210b8b99376 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 16:46:30 +0000 Subject: [PATCH 1/6] Fix hanging entity obstruction cause for frames/paintings Classify HangingBreakEvent as OBSTRUCTION from full-BB getCubes collision instead of a single BlockPosition air sample, which missed large paintings and partial overlaps. Valid placements unchanged. Co-authored-by: xii69 --- .../windspigot/entity/HangingBreakCauses.java | 27 ++++++ .../net/minecraft/server/EntityHanging.java | 25 +++-- .../entity/HangingBreakCausesTest.java | 92 +++++++++++++++++++ 3 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 WindSpigot-Server/src/main/java/com/windpvp/windspigot/entity/HangingBreakCauses.java create mode 100644 WindSpigot-Server/src/test/java/com/windpvp/windspigot/entity/HangingBreakCausesTest.java diff --git a/WindSpigot-Server/src/main/java/com/windpvp/windspigot/entity/HangingBreakCauses.java b/WindSpigot-Server/src/main/java/com/windpvp/windspigot/entity/HangingBreakCauses.java new file mode 100644 index 000000000..3a95aba95 --- /dev/null +++ b/WindSpigot-Server/src/main/java/com/windpvp/windspigot/entity/HangingBreakCauses.java @@ -0,0 +1,27 @@ +package com.windpvp.windspigot.entity; + +import org.bukkit.event.hanging.HangingBreakEvent; + +/** + * Classifies why a hanging entity (item frame / painting) failed {@code survives()}. + *

+ * Obstruction must be decided from solid collision geometry intersecting the hanging + * bounding box — not from a single {@code BlockPosition} material sample at the entity + * origin, which misses large paintings and partial block overlaps. + */ +public final class HangingBreakCauses { + + private HangingBreakCauses() { + } + + /** + * @param obstructedByBlocks {@code true} when {@code world.getCubes(hanging, bb)} is + * non-empty (same criterion as the first {@code survives()} check) + * @return {@link HangingBreakEvent.RemoveCause#OBSTRUCTION} when clipped by a block, + * otherwise {@link HangingBreakEvent.RemoveCause#PHYSICS} (support gone, overlap, etc.) + */ + public static HangingBreakEvent.RemoveCause whenUnsurviving(boolean obstructedByBlocks) { + return obstructedByBlocks ? HangingBreakEvent.RemoveCause.OBSTRUCTION + : HangingBreakEvent.RemoveCause.PHYSICS; + } +} diff --git a/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java b/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java index 3ab26c5d7..0ca1165f8 100644 --- a/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java +++ b/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java @@ -10,6 +10,7 @@ import org.bukkit.event.hanging.HangingBreakEvent; import org.bukkit.event.painting.PaintingBreakEvent; // CraftBukkit end +import com.windpvp.windspigot.entity.HangingBreakCauses; public abstract class EntityHanging extends Entity { @@ -104,15 +105,11 @@ public void t_() { this.c = 0; if (!this.dead && !this.survives()) { // CraftBukkit start - fire break events - Material material = this.world.getType(new BlockPosition(this)).getBlock().getMaterial(); - HangingBreakEvent.RemoveCause cause; - - if (!material.equals(Material.AIR)) { - // TODO: This feels insufficient to catch 100% of suffocation cases - cause = HangingBreakEvent.RemoveCause.OBSTRUCTION; - } else { - cause = HangingBreakEvent.RemoveCause.PHYSICS; - } + // Obstruction = solid collision geometry in the hanging BB (same criterion as + // survives()' first check). Sampling only BlockPosition(this) for non-AIR + // misses large paintings and partial overlaps where the origin cell is still air. + HangingBreakEvent.RemoveCause cause = HangingBreakCauses + .whenUnsurviving(this.isObstructedByBlocks()); HangingBreakEvent event = new HangingBreakEvent((Hanging) this.getBukkitEntity(), cause); this.world.getServer().getPluginManager().callEvent(event); @@ -137,8 +134,16 @@ public void t_() { } + /** + * True when solid block collision boxes intersect this hanging entity's bounding box + * (item frame / painting "suffocation" / obstruction by a placed block). + */ + public boolean isObstructedByBlocks() { + return !this.world.getCubes(this, this.getBoundingBox()).isEmpty(); + } + public boolean survives() { - if (!this.world.getCubes(this, this.getBoundingBox()).isEmpty()) { + if (this.isObstructedByBlocks()) { return false; } else { int i = Math.max(1, this.l() / 16); diff --git a/WindSpigot-Server/src/test/java/com/windpvp/windspigot/entity/HangingBreakCausesTest.java b/WindSpigot-Server/src/test/java/com/windpvp/windspigot/entity/HangingBreakCausesTest.java new file mode 100644 index 000000000..2f0d28ee4 --- /dev/null +++ b/WindSpigot-Server/src/test/java/com/windpvp/windspigot/entity/HangingBreakCausesTest.java @@ -0,0 +1,92 @@ +package com.windpvp.windspigot.entity; + +import org.bukkit.event.hanging.HangingBreakEvent; +import org.junit.Assert; +import org.junit.Test; + +import net.minecraft.server.AxisAlignedBB; +import net.minecraft.server.BlockPosition; +import net.minecraft.server.EntityHanging; +import net.minecraft.server.EnumDirection; +import net.minecraft.server.MathHelper; + +/** + * Regression for EntityHanging suffocation / obstruction cause (TODO #11). + *

+ * The old check sampled only {@code BlockPosition(entity)} for non-AIR. Large paintings and + * partial overlaps can fail {@code survives()} via {@code getCubes} while that origin cell is + * still air — plugins then saw PHYSICS instead of OBSTRUCTION. + */ +public class HangingBreakCausesTest { + + @Test + public void obstructedByBlocksMapsToObstruction() { + Assert.assertEquals(HangingBreakEvent.RemoveCause.OBSTRUCTION, + HangingBreakCauses.whenUnsurviving(true)); + } + + @Test + public void supportOrOverlapFailureMapsToPhysics() { + Assert.assertEquals(HangingBreakEvent.RemoveCause.PHYSICS, + HangingBreakCauses.whenUnsurviving(false)); + } + + @Test + public void largePaintingBoundingBoxSpansMultipleBlockCells() { + // 4×4 block painting (64×64 px) facing south — origin cell alone cannot cover the BB. + BlockPosition origin = new BlockPosition(10, 70, 20); + AxisAlignedBB bb = EntityHanging.calculateBoundingBox(origin, EnumDirection.SOUTH, 64, 64); + + int minX = MathHelper.floor(bb.a); + int maxX = MathHelper.floor(bb.d); + int minY = MathHelper.floor(bb.b); + int maxY = MathHelper.floor(bb.e); + + Assert.assertTrue("painting should span more than one X cell", maxX > minX); + Assert.assertTrue("painting should span more than one Y cell", maxY > minY); + } + + @Test + public void singleOriginCellSampleMissesEdgeObstructionCells() { + BlockPosition origin = new BlockPosition(10, 70, 20); + AxisAlignedBB bb = EntityHanging.calculateBoundingBox(origin, EnumDirection.SOUTH, 64, 64); + + int originX = origin.getX(); + int originY = origin.getY(); + int minX = MathHelper.floor(bb.a); + int maxX = MathHelper.floor(bb.d); + int minY = MathHelper.floor(bb.b); + int maxY = MathHelper.floor(bb.e); + + boolean hasCellOutsideOrigin = false; + for (int x = minX; x <= maxX; x++) { + for (int y = minY; y <= maxY; y++) { + if (x != originX || y != originY) { + hasCellOutsideOrigin = true; + break; + } + } + } + + Assert.assertTrue( + "edge cells exist outside BlockPosition(origin); a non-AIR sample there alone is not enough", + hasCellOutsideOrigin); + // A block placed only in an edge cell leaves origin air — old heuristic → PHYSICS; + // getCubes / whenUnsurviving(true) → OBSTRUCTION. + Assert.assertEquals(HangingBreakEvent.RemoveCause.OBSTRUCTION, + HangingBreakCauses.whenUnsurviving(true)); + } + + @Test + public void itemFrameSizedBoxStaysInOneHorizontalCell() { + BlockPosition origin = new BlockPosition(5, 64, 5); + // Item frames are 12×12 px (EntityItemFrame.l/m). + AxisAlignedBB bb = EntityHanging.calculateBoundingBox(origin, EnumDirection.NORTH, 12, 12); + + Assert.assertEquals(origin.getX(), MathHelper.floor((bb.a + bb.d) / 2.0D)); + Assert.assertEquals(origin.getZ(), MathHelper.floor((bb.c + bb.f) / 2.0D)); + // Thin facing-axis extent must remain sub-block so valid wall placements still survive. + Assert.assertTrue("frame depth should be thinner than one block", (bb.f - bb.c) < 1.0D); + Assert.assertTrue("frame width should be thinner than one block", (bb.d - bb.a) < 1.0D); + } +} From 045eb0ff5972d64bfd8e8afc664c79c2ae02cafd Mon Sep 17 00:00:00 2001 From: Elias Date: Wed, 14 Oct 2026 20:22:16 +0200 Subject: [PATCH 2/6] Update EntityHanging.java --- .../net/minecraft/server/EntityHanging.java | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java b/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java index 0ca1165f8..c3710c02e 100644 --- a/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java +++ b/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java @@ -105,11 +105,13 @@ public void t_() { this.c = 0; if (!this.dead && !this.survives()) { // CraftBukkit start - fire break events - // Obstruction = solid collision geometry in the hanging BB (same criterion as - // survives()' first check). Sampling only BlockPosition(this) for non-AIR - // misses large paintings and partial overlaps where the origin cell is still air. - HangingBreakEvent.RemoveCause cause = HangingBreakCauses - .whenUnsurviving(this.isObstructedByBlocks()); + HangingBreakEvent.RemoveCause cause; + + if (!this.world.getCubes(this, this.getBoundingBox()).isEmpty()) { + cause = HangingBreakEvent.RemoveCause.OBSTRUCTION; + } else { + cause = HangingBreakEvent.RemoveCause.PHYSICS; + } HangingBreakEvent event = new HangingBreakEvent((Hanging) this.getBukkitEntity(), cause); this.world.getServer().getPluginManager().callEvent(event); @@ -134,16 +136,8 @@ public void t_() { } - /** - * True when solid block collision boxes intersect this hanging entity's bounding box - * (item frame / painting "suffocation" / obstruction by a placed block). - */ - public boolean isObstructedByBlocks() { - return !this.world.getCubes(this, this.getBoundingBox()).isEmpty(); - } - public boolean survives() { - if (this.isObstructedByBlocks()) { + if (!this.world.getCubes(this, this.getBoundingBox()).isEmpty()) { return false; } else { int i = Math.max(1, this.l() / 16); From 35ec81c5a8a67398fa65623055785890adfce7ae Mon Sep 17 00:00:00 2001 From: Elias Date: Wed, 14 Oct 2026 20:22:34 +0200 Subject: [PATCH 3/6] Delete WindSpigot-Server/src/main/java/com/windpvp/windspigot/entity/HangingBreakCauses.java --- .../windspigot/entity/HangingBreakCauses.java | 27 ------------------- 1 file changed, 27 deletions(-) delete mode 100644 WindSpigot-Server/src/main/java/com/windpvp/windspigot/entity/HangingBreakCauses.java diff --git a/WindSpigot-Server/src/main/java/com/windpvp/windspigot/entity/HangingBreakCauses.java b/WindSpigot-Server/src/main/java/com/windpvp/windspigot/entity/HangingBreakCauses.java deleted file mode 100644 index 3a95aba95..000000000 --- a/WindSpigot-Server/src/main/java/com/windpvp/windspigot/entity/HangingBreakCauses.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.windpvp.windspigot.entity; - -import org.bukkit.event.hanging.HangingBreakEvent; - -/** - * Classifies why a hanging entity (item frame / painting) failed {@code survives()}. - *

- * Obstruction must be decided from solid collision geometry intersecting the hanging - * bounding box — not from a single {@code BlockPosition} material sample at the entity - * origin, which misses large paintings and partial block overlaps. - */ -public final class HangingBreakCauses { - - private HangingBreakCauses() { - } - - /** - * @param obstructedByBlocks {@code true} when {@code world.getCubes(hanging, bb)} is - * non-empty (same criterion as the first {@code survives()} check) - * @return {@link HangingBreakEvent.RemoveCause#OBSTRUCTION} when clipped by a block, - * otherwise {@link HangingBreakEvent.RemoveCause#PHYSICS} (support gone, overlap, etc.) - */ - public static HangingBreakEvent.RemoveCause whenUnsurviving(boolean obstructedByBlocks) { - return obstructedByBlocks ? HangingBreakEvent.RemoveCause.OBSTRUCTION - : HangingBreakEvent.RemoveCause.PHYSICS; - } -} From 2f8d504d00fe82f8731153b1dd67c5a7d773f9bd Mon Sep 17 00:00:00 2001 From: Elias Date: Wed, 14 Oct 2026 20:22:43 +0200 Subject: [PATCH 4/6] Delete WindSpigot-Server/src/test/java/com/windpvp/windspigot/entity/HangingBreakCausesTest.java --- .../entity/HangingBreakCausesTest.java | 92 ------------------- 1 file changed, 92 deletions(-) delete mode 100644 WindSpigot-Server/src/test/java/com/windpvp/windspigot/entity/HangingBreakCausesTest.java diff --git a/WindSpigot-Server/src/test/java/com/windpvp/windspigot/entity/HangingBreakCausesTest.java b/WindSpigot-Server/src/test/java/com/windpvp/windspigot/entity/HangingBreakCausesTest.java deleted file mode 100644 index 2f0d28ee4..000000000 --- a/WindSpigot-Server/src/test/java/com/windpvp/windspigot/entity/HangingBreakCausesTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.windpvp.windspigot.entity; - -import org.bukkit.event.hanging.HangingBreakEvent; -import org.junit.Assert; -import org.junit.Test; - -import net.minecraft.server.AxisAlignedBB; -import net.minecraft.server.BlockPosition; -import net.minecraft.server.EntityHanging; -import net.minecraft.server.EnumDirection; -import net.minecraft.server.MathHelper; - -/** - * Regression for EntityHanging suffocation / obstruction cause (TODO #11). - *

- * The old check sampled only {@code BlockPosition(entity)} for non-AIR. Large paintings and - * partial overlaps can fail {@code survives()} via {@code getCubes} while that origin cell is - * still air — plugins then saw PHYSICS instead of OBSTRUCTION. - */ -public class HangingBreakCausesTest { - - @Test - public void obstructedByBlocksMapsToObstruction() { - Assert.assertEquals(HangingBreakEvent.RemoveCause.OBSTRUCTION, - HangingBreakCauses.whenUnsurviving(true)); - } - - @Test - public void supportOrOverlapFailureMapsToPhysics() { - Assert.assertEquals(HangingBreakEvent.RemoveCause.PHYSICS, - HangingBreakCauses.whenUnsurviving(false)); - } - - @Test - public void largePaintingBoundingBoxSpansMultipleBlockCells() { - // 4×4 block painting (64×64 px) facing south — origin cell alone cannot cover the BB. - BlockPosition origin = new BlockPosition(10, 70, 20); - AxisAlignedBB bb = EntityHanging.calculateBoundingBox(origin, EnumDirection.SOUTH, 64, 64); - - int minX = MathHelper.floor(bb.a); - int maxX = MathHelper.floor(bb.d); - int minY = MathHelper.floor(bb.b); - int maxY = MathHelper.floor(bb.e); - - Assert.assertTrue("painting should span more than one X cell", maxX > minX); - Assert.assertTrue("painting should span more than one Y cell", maxY > minY); - } - - @Test - public void singleOriginCellSampleMissesEdgeObstructionCells() { - BlockPosition origin = new BlockPosition(10, 70, 20); - AxisAlignedBB bb = EntityHanging.calculateBoundingBox(origin, EnumDirection.SOUTH, 64, 64); - - int originX = origin.getX(); - int originY = origin.getY(); - int minX = MathHelper.floor(bb.a); - int maxX = MathHelper.floor(bb.d); - int minY = MathHelper.floor(bb.b); - int maxY = MathHelper.floor(bb.e); - - boolean hasCellOutsideOrigin = false; - for (int x = minX; x <= maxX; x++) { - for (int y = minY; y <= maxY; y++) { - if (x != originX || y != originY) { - hasCellOutsideOrigin = true; - break; - } - } - } - - Assert.assertTrue( - "edge cells exist outside BlockPosition(origin); a non-AIR sample there alone is not enough", - hasCellOutsideOrigin); - // A block placed only in an edge cell leaves origin air — old heuristic → PHYSICS; - // getCubes / whenUnsurviving(true) → OBSTRUCTION. - Assert.assertEquals(HangingBreakEvent.RemoveCause.OBSTRUCTION, - HangingBreakCauses.whenUnsurviving(true)); - } - - @Test - public void itemFrameSizedBoxStaysInOneHorizontalCell() { - BlockPosition origin = new BlockPosition(5, 64, 5); - // Item frames are 12×12 px (EntityItemFrame.l/m). - AxisAlignedBB bb = EntityHanging.calculateBoundingBox(origin, EnumDirection.NORTH, 12, 12); - - Assert.assertEquals(origin.getX(), MathHelper.floor((bb.a + bb.d) / 2.0D)); - Assert.assertEquals(origin.getZ(), MathHelper.floor((bb.c + bb.f) / 2.0D)); - // Thin facing-axis extent must remain sub-block so valid wall placements still survive. - Assert.assertTrue("frame depth should be thinner than one block", (bb.f - bb.c) < 1.0D); - Assert.assertTrue("frame width should be thinner than one block", (bb.d - bb.a) < 1.0D); - } -} From 02ab84a70d3e5879a80d8ddc86255767ddb8acaf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 21:11:46 +0000 Subject: [PATCH 5/6] Fix hanging entity obstruction cause for frames/paintings Co-authored-by: xii69 --- .../src/main/java/net/minecraft/server/EntityHanging.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java b/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java index 3ab26c5d7..c3710c02e 100644 --- a/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java +++ b/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java @@ -10,6 +10,7 @@ import org.bukkit.event.hanging.HangingBreakEvent; import org.bukkit.event.painting.PaintingBreakEvent; // CraftBukkit end +import com.windpvp.windspigot.entity.HangingBreakCauses; public abstract class EntityHanging extends Entity { @@ -104,11 +105,9 @@ public void t_() { this.c = 0; if (!this.dead && !this.survives()) { // CraftBukkit start - fire break events - Material material = this.world.getType(new BlockPosition(this)).getBlock().getMaterial(); HangingBreakEvent.RemoveCause cause; - if (!material.equals(Material.AIR)) { - // TODO: This feels insufficient to catch 100% of suffocation cases + if (!this.world.getCubes(this, this.getBoundingBox()).isEmpty()) { cause = HangingBreakEvent.RemoveCause.OBSTRUCTION; } else { cause = HangingBreakEvent.RemoveCause.PHYSICS; From e85ce9c979e7740b6794523da28f90dae4304ab5 Mon Sep 17 00:00:00 2001 From: xii69 Date: Thu, 15 Oct 2026 18:58:27 +0330 Subject: [PATCH 6/6] Remove unused import for HangingBreakCauses --- .../src/main/java/net/minecraft/server/EntityHanging.java | 1 - 1 file changed, 1 deletion(-) diff --git a/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java b/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java index c3710c02e..4af79878b 100644 --- a/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java +++ b/WindSpigot-Server/src/main/java/net/minecraft/server/EntityHanging.java @@ -10,7 +10,6 @@ import org.bukkit.event.hanging.HangingBreakEvent; import org.bukkit.event.painting.PaintingBreakEvent; // CraftBukkit end -import com.windpvp.windspigot.entity.HangingBreakCauses; public abstract class EntityHanging extends Entity {