fix(physics): register BSP-only furniture weenies -- drop premature cyl/sphere/radius gate

RegisterLiveEntityCollision had a premature gate at the top of the method:
  if (!hasCyl && !hasSphere && !hasRadius) return;
This fired BEFORE ShadowShapeBuilder.FromSetup ran. The builder emits a BSP shape
for every Part whose GfxObj has a PhysicsBSP, regardless of CylSpheres/Spheres/Radius.
A furniture weenie with only a physics-BSP mesh (candle holder, candelabra, etc.)
has no CylSpheres, no Spheres, and Radius=0 -- so it was always dropped, making it
fully passable (invisible wall that lets the player walk through it).

Fix: remove the premature gate. The three `bool` locals (hasCyl, hasSphere, hasRadius)
are retained -- `hasRadius` is still used by the Radius fallback lower in the method
for entities with no CylSphere/Sphere/BSP but a non-zero setup.Radius. The correct
final gate at shapes.Count==0 (after builder + Radius fallback) handles all cases:
  - BSP-only entity: builder emits BSP shape -> shapes.Count>0 -> registered.
  - Truly shapeless (no BSP, no cyl, no sphere, no radius): builder empty, no Radius
    fallback fires -> shapes.Count==0 -> return (not registered, passable). Correct.

Retail anchor: CPhysicsObj::FindObjCollisions (acclient_2013_pseudo_c.txt:276917) --
the gate at pc:276917 is on the MOVER's CPartArray, not a target-side shape filter.
CPartArray::FindObjCollisions (pc:286236) iterates ALL parts; each part's
find_obj_collisions tests physics_bsp when present. There is no retail equivalent of
our premature gate that skips BSP-only targets.

Tests (ShadowShapeBuilderShapeSourceTests): two new cases.
  Setup_WithBspPart_NoCylSpheres_EmitsBspShape -- proves the builder emits the shape
    the premature gate was discarding.
  Setup_WithPartButNoBsp_NoCylSpheres_YieldsEmptyShapeList -- regression guard proving
    truly shapeless entities are still not registered (the shapes.Count==0 gate holds).
Full Core suite: 1595 pass / 0 fail / 2 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 21:04:32 +02:00
parent 1a7c5aa006
commit 989cc25d80
2 changed files with 64 additions and 3 deletions

View file

@ -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;

View file

@ -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<CylSphere>(),
Spheres = new List<Sphere>(),
Parts = { BspGfxObjId },
PlacementFrames = new Dictionary<Placement, AnimationFrame>(),
};
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<CylSphere>(),
Spheres = new List<Sphere>(),
Parts = { NoBspGfxObjId },
PlacementFrames = new Dictionary<Placement, AnimationFrame>(),
};
var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f,
hasPhysicsBsp: _ => false);
Assert.Empty(shapes);
}
}