fix(phys): GetNearbyObjects dedup-by-entityId silently drops multi-part shadows

Apparatus test (DoorCollisionApparatusTests) loads door GfxObj 0x010044B5
from the real dat, builds the door entity's shape list via
ShadowShapeBuilder, registers via RegisterMultiPart, and sweeps a player
sphere into the door from three angles. Pre-fix: all three assertions
fail — the sphere walks straight through. The [cyl-test] probe fires
every tick (the small Sphere shape is queried) but no [resolve-bldg] —
the per-Part BSP entry is never reached.

Root cause: ShadowObjectRegistry.GetNearbyObjects deduplicates on
entry.EntityId via HashSet<uint>. Pre-RegisterMultiPart each entity had
exactly one shadow row, so dedup-by-entityId correctly suppressed
multi-cell duplication. After Task 4's RegisterMultiPart introduced
multi-shape rows (1 Sphere + 1 per-Part-BSP for doors; potentially more
for creatures + items), the dedup silently drops everything after the
first. ShadowShapeBuilder emits Sphere shapes before Part-BSPs, so the
Sphere wins and the BSP is dropped — exactly the "Task 7 produced zero
[resolve-bldg] hits" finding from the 2026-05-24 evening handoff.

Fix: dedup on the full ShadowEntry. record-struct equality compares
all fields (EntityId, GfxObjId, Position, Rotation, Radius,
CollisionType, CylHeight, Scale, State, Flags, LocalPosition,
LocalRotation). Distinct shapes of the same entity are not equal and
make it through; the same shape registered in multiple cells (its
fields identical across calls) dedups exactly as before.

Apparatus verification post-fix: all 4 tests pass.
  - Dead-center front approach: BLOCKED at Y=11.5 normal=(0,-1,0).
  - 50 cm off-center: BLOCKED at Y=11.5 normal=(0,-1,0).
  - Back approach from inside: BLOCKED at Y=12.8 normal=(0,+1,0).
  - Diagnostic dump: BSP fires at tick 5.

What this fix DOES NOT do: switch live RegisterLiveEntityCollision to
use ShadowShapeBuilder + RegisterMultiPart. That's Task 7 of the
original plan, still reverted. With this foundation fix in place,
Task 7 should now actually deliver door blocking in production.

Test impact: 44/44 in the shape/registry/door scope pass. The broader
Physics suite shows the pre-existing PhysicsResolveCapture
static-state flakiness documented in CLAUDE.md — 6 baseline failures
without my new tests, 10 with them (4 extra are my apparatus tests'
IsPlayer-flag resolves getting captured by a concurrent Capture-test
race). Independent of this fix; verified by isolating each test
class.

Findings + apparatus reasoning:
docs/research/2026-05-24-door-dat-inspection-findings.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-24 18:47:04 +02:00
parent e1d94d7094
commit 3b7dc46219
2 changed files with 343 additions and 3 deletions

View file

@ -434,7 +434,20 @@ public sealed class ShadowObjectRegistry
uint primaryCellId = 0u)
{
results.Clear();
var seen = new HashSet<uint>();
// A6.P4 door fix (2026-05-24): dedup on the full ShadowEntry rather
// than entity id. Pre-RegisterMultiPart each entity had exactly one
// shadow, so dedup-by-entityId correctly suppressed multi-cell
// duplication. With multi-part entities (a door has 1 Sphere + 1
// per-Part-BSP = 2 entries with the same EntityId; creatures can
// have more), an entityId dedup silently dropped every shape after
// the first — the door's BSP slab never reached BSPQuery in the
// 2026-05-24 apparatus reproduction. ShadowEntry's record-struct
// equality compares all fields (incl. GfxObjId, LocalPosition,
// CollisionType) so distinct shapes of the same entity make it
// through, while a single shape registered across multiple cells
// (its position + radius equal across calls) deduplicates exactly
// as before.
var seen = new HashSet<ShadowEntry>();
// Cells reachable from the sphere's primary cell via the portal graph
// (output of CellTransit.FindCellSet). This set holds the primary
@ -450,7 +463,7 @@ public sealed class ShadowObjectRegistry
if (!_cells.TryGetValue(cellId, out var list)) continue;
foreach (var entry in list)
{
if (seen.Add(entry.EntityId))
if (seen.Add(entry))
results.Add(entry);
}
}
@ -503,7 +516,7 @@ public sealed class ShadowObjectRegistry
foreach (var entry in list)
{
if (seen.Add(entry.EntityId))
if (seen.Add(entry))
results.Add(entry);
}
}