diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c730f0e7..eefa3325 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3763,9 +3763,18 @@ public sealed class GameWindow : IDisposable bool hasSphere = setup.Spheres.Count > 0; bool hasRadius = setup.Radius > 0.0001f; - // Retail-faithful phantom skip (acclient_2013_pseudo_c.txt:276917). - if (!hasCyl && !hasSphere && !hasRadius) - return; + // NOTE: We intentionally do NOT gate here on `!hasCyl && !hasSphere && !hasRadius`. + // That premature check was wrong: ShadowShapeBuilder.FromSetup also emits a BSP shape + // for any Part whose GfxObj has a non-null PhysicsBSP (e.g. a candle holder or floor + // candelabra with only a BSP collision mesh, no CylSphere/Sphere/Radius). Gating here + // would silently drop those BSP-only entities before the builder runs, making them + // invisible walls or fully passable. The correct final gate is at shapes.Count==0 + // below (after the builder + Radius fallback have run), which correctly handles ALL + // cases: BSP-only -> builder emits BSP shape -> registered; truly shapeless -> builder + // empty, no Radius fallback -> shapes.Count==0 -> return. Retail eligibility is + // "has physics_bsp OR cylsphere OR sphere" per CPhysicsObj::FindObjCollisions + // (acclient_2013_pseudo_c.txt:276917 context: the gate fires on the MOVER, not the + // target; no equivalent target-side gate skips BSP-only objects). float entScale = spawn.ObjScale ?? 1.0f; diff --git a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs index a58a7eca..7677b548 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs @@ -38,4 +38,56 @@ public class ShadowShapeBuilderShapeSourceTests var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: _ => false); Assert.Empty(shapes); } + + // D4+W1 (2026-06-24) — BSP-only furniture weenie guard. + // + // A Setup with no CylSpheres, no Spheres, no Radius, but whose Part has a + // PhysicsBSP (e.g. a candle holder or floor candelabra) MUST produce a BSP + // ShadowShape. The premature `if (!hasCyl && !hasSphere && !hasRadius) return` + // gate in GameWindow.RegisterLiveEntityCollision was dropped because it fired + // BEFORE ShadowShapeBuilder ran and discarded these entities entirely. + // + // Retail oracle: CPhysicsPart::find_obj_collisions@0x0050D8D0 -- when + // physics_bsp is non-null the part IS tested; the outer loop in + // CPartArray::FindObjCollisions iterates all parts regardless of + // CylSpheres/Spheres. ShadowShapeBuilder.FromSetup mirrors this by emitting + // one BSP shape per part that `hasPhysicsBsp` returns true for. + [Fact] + public void Setup_WithBspPart_NoCylSpheres_EmitsBspShape() + { + // hasPhysicsBsp predicate returns true for 0x0100AAAAu, simulating a + // GfxObj that has a PhysicsBSP but no CylSpheres/Spheres. + const uint BspGfxObjId = 0x0100AAAAu; + var setup = new Setup + { + CylSpheres = new List(), + Spheres = new List(), + Parts = { BspGfxObjId }, + PlacementFrames = new Dictionary(), + }; + var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, + hasPhysicsBsp: id => id == BspGfxObjId); + + Assert.Contains(shapes, s => s.CollisionType == ShadowCollisionType.BSP); + } + + // Complementary regression guard: a Setup with no CylSpheres, no Spheres, + // and a Part that has NO PhysicsBSP must still produce an empty shape list. + // The premature gate removal does not accidentally register shapeless entities. + [Fact] + public void Setup_WithPartButNoBsp_NoCylSpheres_YieldsEmptyShapeList() + { + const uint NoBspGfxObjId = 0x0100BBBBu; + var setup = new Setup + { + CylSpheres = new List(), + Spheres = new List(), + Parts = { NoBspGfxObjId }, + PlacementFrames = new Dictionary(), + }; + var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, + hasPhysicsBsp: _ => false); + + Assert.Empty(shapes); + } }