test(phys): A6.P4 door — directional + geometric pin tests reframe inside-out bug

Built three new tests to investigate the inside-out asymmetric collision
that persists after the AddAllOutsideCells coord fix:

1. Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace — sphere
   south of door moving NORTH; expects block with cn.Y less than -0.5
2. Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace — sphere
   north of door moving SOUTH; expects block with cn.Y greater than +0.5
3. Geometric_DoorSlabZRange_AbovePlayerSphereTop — pins the slab Z
   range vs sphere top math

BOTH directional tests PASS — collision is symmetric at unit-test level.
The asymmetric production bug therefore comes from something the unit
tests do not capture (multi-tick state, cell-tracking flicker, walkable
polygon edge interactions).

The geometric pin test reveals the real story: Setup 0x020019FF places
the part-0 BSP slab 1.275 m ABOVE the entity origin via
PlacementFrames[Default][0].Origin. With the cottage door entity at
world Z=94.1, the slab world Z range is [95.375, 97.865]. Player sphere
top reaches Z=95.20. The slab BOTTOM is 0.175 m ABOVE the sphere top —
the slab NEVER collides with the player.

The slab is a LINTEL (door frame above the doorway), not a leaf. The
door's only effective collider at sphere height is the 0.10 m radius
foot cylinder. The directional tests pass because the cylinder blocks,
not the BSP.

User-reported inside-out off-center walkthrough is the sphere walking
AROUND the foot cylinder (sphere reach 0.48 + cyl 0.10 = 0.58 m; any
sphere center over 0.58 m from cylinder center passes freely). The
visual "body partially intersects door" is the character model
occupying the visual door volume while the collision sphere passes
beside the cylinder.

Reframed handoff in docs/research/2026-05-25-door-bug-partial-fix-shipped.md
points to three candidate next-step investigations:
- Retail-faithfulness audit on setup.Radius / setup.Height interpretation
- Re-inspect door parts 1+2 (GfxObj 0x010044B6) for missed physics shapes
- Test the cottage cell BSP (cell 0x0150 walls) + door together — the
  COMBINED collision may be what retail relies on

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-25 08:08:42 +02:00
parent 28cd97be62
commit c27fded61e
2 changed files with 413 additions and 20 deletions

View file

@ -106,29 +106,90 @@ The `[bsp-test]` probe fires 245 times for the door entity during the
post-fix inside-out attempts — door IS being queried. The
collision-detection mechanics produce the wrong response.
## What's next (separate bug)
## What's next (separate bug — REFRAMED 2026-05-25 evening)
**Investigate BSPQuery.FindCollisions's response for two-sided polygons
when the sphere is already overlapping the slab.** Retail's
`CBSPTree::find_collisions` family handles this specifically — the
sphere's path through the slab faces gets traced and the FIRST face
crossed in motion direction is the collision. With two-sided polygons,
both faces are collidable; the front-vs-back determination is by
sphere-velocity vs face-normal dot product.
**Initial hypothesis was wrong.** Two new directional tests built
post-fix (`Directional_OutsideIn_*`, `Directional_InsideOut_*`) BOTH
PASS — the BSP collision response is symmetric at unit-test level.
The asymmetric production bug must come from something the unit tests
weren't capturing.
Likely files:
- `src/AcDream.Core/Physics/BSPQuery.cs` — the BSP traversal +
sphere-poly intersection logic.
- Retail decomp anchors:
`acclient_2013_pseudo_c.txt:BSPTREE::find_collisions` +
`SPHEREPATH::sphere_intersects_poly` family.
A geometric pin test (`Geometric_DoorSlabZRange_AbovePlayerSphereTop`)
reveals the real story:
Apparatus to write next: a focused test that registers the door at its
actual production world transform (entity origin + partFrame offset
from the dat, with correct rotation) and replays a sphere passing
through it from EACH side at various speeds. Compare collision normal
+ position-resolution per side. The asymmetric response will be
reproducible at unit-test speed.
```
Setup 0x020019FF (cottage door):
CylSphere[0]: r=0.10, h=0.20, origin=(0, 0, 0.018)
→ world Z [94.118, 94.318] when entity at Z=94.1
Part 0 (GfxObj 0x010044B5, the BSP "slab"):
placement frame [Default][0].Origin = (-0.006, 0.125, 1.275)
→ BSP world Z [95.375, 97.865] when entity at Z=94.1
Player at floor Z=94:
sphere height = 1.20, sphere top = 95.20
BSP slab BOTTOM (95.375) is ABOVE sphere TOP (95.20) by 0.175 m.
The slab NEVER collides with the player's body sphere.
```
The slab is a LINTEL (the door frame above the doorway), not a leaf.
The door's only collision against a player at floor level is the
0.10 m radius foot cylinder. Sphere radius 0.48 + cyl 0.10 = 0.58 m
collision reach. Any sphere center > 0.58 m from cylinder center
(132.6, 17.1) passes freely.
The user-reported "inside-out walkthrough at ~50 cm off-center" is
the sphere walking AROUND the cylinder, at X = 132.6 ± 0.6+ m where
collision misses entirely. "Body partially intersects door" is the
character model occupying the visual door's volume while the collision
sphere passes beside the foot cylinder.
The "outside-in works" case is also the foot cylinder doing the
blocking — when the user approaches more centered or hits the cylinder
head-on. The cell-visibility fix made the cylinder visible from indoor
cells (it wasn't before), which is why outside-in went from "walks
through" to "blocks" — but the cylinder is the actual collider.
**This is a door-geometry interpretation question, NOT a BSP query bug.**
Three candidate next-step investigations:
1. **Retail-faithfulness audit on door collision.** Read retail's
`CPhysicsObj::set_setup_for_door` (or similar) to determine what
shapes retail loads for a closed cottage door. If retail uses the
SAME setup data + finds the same shapes we do, the door geometry
in the dat IS the spec. The "blocking" the user sees in retail
might similarly be the foot cylinder + perhaps a different default
sphere from setup.Radius/setup.Height that we're not registering.
2. **Inspect door parts 1+2 (GfxObj 0x010044B6).** Per prior session's
handoff they are visual-only (HasPhysics=false). Verify by direct
dat read — maybe the PhysicsBSP is null but the cylinder/sphere
list on the GfxObj itself has collision data we're missing.
`DoorSetupGfxObjInspectionTests` already prints this; re-read.
3. **The cottage cell BSP encloses the doorway.** Cell 0x0150
(the doorway alcove) is bounded by cottage walls. The DOOR opens
through a gap in those walls. When the door is closed, the gap
is filled by the door geometry. Maybe retail's collision relies
on the cottage walls (cell BSP) for the "doorway side walls" and
the door's slab covers ONLY the opening's leaf area. The asymmetric
block we see could be the cottage cell walls catching outside-in
approach (cell BSP collision via FindEnvCollisions, before the
door's shadow ever fires) but NOT catching inside-out at the same
alcove geometry.
The 50cm off-center approach probably exits the cottage walls' BSP
through the doorway gap, walks past the foot cylinder (which is small),
and never has anything to collide against in the world's collision
representation. Retail might block this via a `setup.Radius=0.1414`
cylinder we're missing (Setup.Radius isn't currently registered by
`ShadowShapeBuilder.FromSetup` for entities WITH a CylSphere).
Apparatus to write next: a test that registers the door using
ShadowShapeBuilder.FromSetup AND verifies setup.Radius is reflected
in the shape list. If it isn't, that's a candidate fix — the door's
0.14 m radius + 0.20 m height SETUP sphere is wider than the 0.10 m
CylSphere and would catch the off-center approach.
## Commits