fix(test): correct geometric pin test for door slab Z math

The Geometric_DoorSlabZRange_AbovePlayerSphereTop test was computing
slabWorldZBottom as (entity.Z + partFrame.Z) — assuming the slab's
local Z=0 was its bottom. Actually checking the dat shows the slab's
PhysicsPolygons local AABB is min=(-0.954, -0.134, -1.236) max=(0.971,
0.127, 1.255) — the slab's local origin is at its GEOMETRIC CENTER,
not the bottom. With partFrame.Z=1.275 lifting the origin, the slab
world Z is actually [94.139, 96.630], not [95.375, 97.865].

Corrected test now computes both slabLocalZMin and slabLocalZMax from
the polygon vertices and asserts the opposite (correct) geometric fact:
the slab IS at sphere height — overlap from Z=94.139 to Z=95.20 (1.061
m of vertical overlap with the player's sphere). The slab is NOT a
lintel that misses the sphere; it should collide.

Test renamed: Geometric_DoorSlabZRange_AbovePlayerSphereTop →
Geometric_DoorSlabAtSphereHeight_OverlapsInZ.

Handoff doc 2026-05-25-door-bug-partial-fix-shipped.md updated with
the corrected analysis. The "next investigation candidates" list now
points toward cdb attach to retail as the highest-ROI option, since
the BSP collision IS active at sphere height but production still
shows asymmetric walkthrough behavior. The bug is in either the
GetNearbyObjects coverage at primary-cell boundaries, the BSP
polygon partial-overlap handling, or missing cell-BSP collision for
cottage doorway walls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-25 08:14:49 +02:00
parent c27fded61e
commit 85a164f4a8
2 changed files with 125 additions and 129 deletions

View file

@ -106,90 +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 — REFRAMED 2026-05-25 evening)
## What's next (separate bug)
**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.
**Investigation status (corrected 2026-05-25 late evening).** Two new
directional tests + a geometric pin test all PASS:
A geometric pin test (`Geometric_DoorSlabZRange_AbovePlayerSphereTop`)
reveals the real story:
- `Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace` PASSES.
- `Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace` PASSES.
- `Geometric_DoorSlabAtSphereHeight_OverlapsInZ` PASSES.
The geometric test reveals (correctly computed this time):
```
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
Setup 0x020019FF (cottage door) PhysicsPolygons local AABB:
min=(-0.954, -0.134, -1.236) max=(0.971, 0.127, 1.255)
(slab origin at GEOMETRIC CENTER, not the bottom)
Player at floor Z=94:
sphere height = 1.20, sphere top = 95.20
partFrame[0].Origin = (-0.006, 0.125, 1.275) → lifts slab origin
1.275 m above entity Z
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.
With entity at world (132.6, 17.1, 94.1) + 180° entity rotation:
partWorldPos = (132.606, 16.975, 95.375)
Slab WORLD AABB:
X: [131.635, 133.560] (1.925 m wide)
Y: [16.848, 17.109] (0.261 m thick)
Z: [94.139, 96.630] (2.491 m tall, bottom JUST above floor)
Player sphere at foot Z=94:
Z: [94, 95.20]
Slab DOES overlap sphere in Z (overlap Z=[94.139, 95.20] = 1.061 m).
```
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 slab IS at sphere height — it should collide.** Both directional
tests prove BSP collision response is symmetric for sphere-to-slab
approach. Yet production shows asymmetric inside-out walkthrough at
off-center positions. The bug must be in one of:
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.
1. **The portal-reachable cells from indoor cell 0x0150 still miss the
door's shadow at certain sphere positions**, despite the
AddAllOutsideCells fix. The user's walkthrough at X=133.655 (1.05 m
east of door center) puts the sphere mostly east of slab X range
[131.635, 133.560]. The sphere's WEST edge (X=133.175) is barely
inside the slab. If GetNearbyObjects's outdoor radial sweep uses
sphere center XY for cell lookup, it computes
gridX = (int)(133.655 / 24) = 5 → cell 0xA9B40029. But AddAllOutsideCells
only adds cells based on the sphere's PRIMARY position. The east-cell
neighbor might not be added if the sphere is wholly within the primary
cell's grid XY. Worth verifying.
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.
2. **The BSP polygon-level test for partial-overlap geometry.** Sphere
half-east-of-slab, sphere south edge at slab north edge, moving +Y:
sphere is on the verge of leaving the slab volume. BSPQuery's polygon
intersection might consider this a "leaving collision" with no
response, even though the sphere body still partially occupies the
slab volume. Retail might handle this as "depenetration push" to
resolve the overlap.
**This is a door-geometry interpretation question, NOT a BSP query bug.**
Three candidate next-step investigations:
3. **Cell BSP (cell 0x0150's PhysicsPolygons) is missing**. The doorway
alcove cell has 4 physics polygons — likely walls + floor. If retail
relies on the cell's walls to catch sphere-vs-doorway-side-wall
collisions (in addition to the door slab), and we're not loading /
testing the cell BSP correctly for the player's foot at sphere
height, the side walls would miss.
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.
Three candidate investigations, ranked by ROI:
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.
**A. cdb attach to retail** at a Holtburg cottage doorway. Break on
`CTransition::FindObjCollisions` for the door entity. Inspect what
shapes retail actually tests against. THIS IS DEFINITIVE — answers
"what should we be doing differently" in 15-30 min. CLAUDE.md has the
toolchain ready.
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.
**B. Reproduce inside-out walkthrough at unit-test speed.** Load real
cell 0x0150 BSP into the harness (via CacheCellStruct from dat) +
register door at faithful transform + replay captured tick 3262.
If walkthrough reproduces at unit speed, can iterate on the fix in
<500 ms.
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).
**C. Audit GetNearbyObjects radial sweep + AddAllOutsideCells coverage**
for east-neighbor cell when sphere XY is at primary cell boundary.
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.
Recommendation: **A first** (cdb), then **B** to validate the fix at
unit-test speed.
## Commits

View file

@ -300,9 +300,9 @@ public class DoorBugTrajectoryReplayTests
/// </para>
/// </summary>
/// <summary>
/// Geometric finding (2026-05-25 evening) — pins the door geometry
/// math that explains why the "inside-out walkthrough" persists
/// after the cell-visibility fix.
/// Geometric pin (2026-05-25 evening, CORRECTED) — pins where the
/// cottage door's BSP slab actually lives in world space relative
/// to the player's sphere.
///
/// <para>
/// The cottage door Setup 0x020019FF has:
@ -319,58 +319,55 @@ public class DoorBugTrajectoryReplayTests
/// </para>
///
/// <para>
/// With entity at (132.6, 17.1, 94.1) (the captured Holtburg cottage
/// door spawn position):
/// AABB measured: <c>min=(-0.954,-0.134,-1.236) max=(0.971,0.127,1.255)</c>.
/// The slab's local origin is at the slab's GEOMETRIC CENTER (each
/// axis is roughly symmetric around 0). With partFrame.Z = +1.275
/// lifting the local origin up from the entity, the slab's world
/// extents are:
/// </para>
///
/// <list type="bullet">
/// <item>Cylinder world Z range: [94.118, 94.318] — touches the
/// ground (sphere foot Z=94 to top Z=95.20 overlaps cyl Z up to 94.318).</item>
/// <item>Slab world Z range: [95.375, 97.865]. <b>The slab's BOTTOM
/// (95.375 m) is 0.175 m ABOVE the player's sphere top
/// (95.20 m).</b> The slab NEVER intersects the player's
/// body sphere vertically.</item>
/// <item>X: [131.635, 133.560] (1.925 m wide; after 180° entity Z rot)</item>
/// <item>Y: [16.848, 17.109] (0.261 m thick)</item>
/// <item><b>Z: [94.139, 96.630]</b> (2.491 m tall, bottom JUST above floor)</item>
/// </list>
///
/// <para>
/// Player sphere (radius 0.48, height 1.20) at floor Z=94 extends
/// Z=[94, 95.20]. Slab bottom (94.139) is BELOW sphere top (95.20)
/// by 1.061 m. <b>The slab DOES overlap the sphere in Z</b> over
/// world Z range [94.139, 95.20]. The slab is at sphere height,
/// not above it.
/// </para>
///
/// <para>
/// Implication: the door's only effective collision against a player
/// at floor level is the 0.10 m radius foot cylinder. The 1.93 m wide
/// slab is collision-irrelevant at sphere height — it's a LINTEL
/// (the door frame above), not a leaf collision. The user-reported
/// "off-center walkthrough" is the sphere walking AROUND the
/// 0.10 m foot cylinder (sphere reach 0.48 + cyl 0.10 = 0.58 m;
/// any sphere center &gt;0.58 m from cylinder center passes freely).
/// The "body partially intersects door" is the rendered character model
/// occupying volume the door visual fills, but no collision body to
/// stop it because the slab is too high.
/// The foot cylinder (r=0.10, h=0.20) sits at world Z [94.118, 94.318]
/// — barely above the floor, well within the sphere's foot region.
/// </para>
///
/// <para>
/// Next session: this is a DAT or registration issue, not a BSP query
/// bug. Options:
/// <list type="number">
/// <item>Verify retail actually USES this geometry — maybe the door
/// relies on the cottage CELL'S walls (cell 0x0150's
/// PhysicsPolygons) to enclose the doorway, and the door's
/// only collision is the foot cylinder + a leaf shape we're
/// missing.</item>
/// <item>Inspect parts 1+2 (the door LEAVES, GfxObj 0x010044B6) to
/// confirm they're truly visual-only or if we missed a
/// physics shape.</item>
/// <item>cdb attach to retail at a Holtburg cottage door — set a
/// breakpoint on CTransition::FindObjCollisions for the door
/// entity and inspect what shapes retail tests against.</item>
/// </list>
/// Both shapes are at collision-able height. So the post-fix
/// inside-out walkthrough at off-center positions is NOT explained
/// by the slab being above the sphere. The bug must be in the BSP
/// polygon-level collision response, OR in how the multi-cell
/// portal-reachable cells produce the shapes list for a player on
/// the indoor side of the doorway. Next session investigation:
/// add a focused test that replays the captured inside-out
/// walkthrough tick with the door registered at its FAITHFUL
/// production transform (180° entity rot + dat-loaded partFrame)
/// and shows what BSPQuery.FindCollisions actually does at that
/// tick.
/// </para>
/// </summary>
[Fact]
public void Geometric_DoorSlabZRange_AbovePlayerSphereTop()
public void Geometric_DoorSlabAtSphereHeight_OverlapsInZ()
{
var datDir = ResolveDatDir();
if (datDir is null) return;
// Load the door setup + part 0's PlacementFrame.
DatReaderWriter.Types.Frame partFrame;
float slabLocalZMax;
float slabLocalZMin = float.MaxValue;
float slabLocalZMax = float.MinValue;
using (var dats = new DatCollection(datDir, DatAccessType.Read))
{
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(0x020019FFu)!;
@ -380,38 +377,37 @@ public class DoorBugTrajectoryReplayTests
var gfx = dats.Get<DatReaderWriter.DBObjs.GfxObj>(DoorGfxObjId)!;
Assert.NotNull(gfx.PhysicsPolygons);
// Compute local AABB Z max from vertex array.
slabLocalZMax = float.MinValue;
// Walk every physics polygon vertex to find local Z extents.
foreach (var poly in gfx.PhysicsPolygons.Values)
{
foreach (ushort vid in poly.VertexIds)
{
if (gfx.VertexArray.Vertices.TryGetValue(vid, out var sv))
if (sv.Origin.Z > slabLocalZMax) slabLocalZMax = sv.Origin.Z;
if (!gfx.VertexArray.Vertices.TryGetValue(vid, out var sv)) continue;
if (sv.Origin.Z < slabLocalZMin) slabLocalZMin = sv.Origin.Z;
if (sv.Origin.Z > slabLocalZMax) slabLocalZMax = sv.Origin.Z;
}
}
}
// The door's entity is placed at world Z=94.1 in Holtburg (per
// captured spawn log). Slab local Z=0 origin offsets to:
float slabWorldZBottom = DoorSpawnPos.Z + partFrame.Origin.Z;
float slabWorldZTop = slabWorldZBottom + slabLocalZMax;
// Slab local origin shifted up by partFrame.Z. Slab world Z extents:
float partWorldZ = DoorSpawnPos.Z + partFrame.Origin.Z;
float slabWorldZBottom = partWorldZ + slabLocalZMin;
float slabWorldZTop = partWorldZ + slabLocalZMax;
// The player has sphereHeight=1.20, sphereRadius=0.48. Sphere top
// in world = foot.Z + height - radius + radius = foot.Z + height
// (the top of the head sphere centered at foot.Z + height - radius).
const float SphereHeight = 1.20f;
const float PlayerFootZ = 94f; // standard Holtburg floor
float sphereTopZ = PlayerFootZ + SphereHeight;
const float SphereHeight = 1.20f;
const float PlayerFootZ = 94f;
float sphereTopZ = PlayerFootZ + SphereHeight;
// The crucial assertion: slab bottom is above sphere top.
Assert.True(slabWorldZBottom > sphereTopZ,
$"Door slab bottom ({slabWorldZBottom:F3}) should be ABOVE " +
$"player sphere top ({sphereTopZ:F3}). Gap = " +
$"{slabWorldZBottom - sphereTopZ:F3} m. This pins the geometric " +
$"fact that the slab does not collide with the player at floor " +
$"level — only the foot cylinder does. The inside-out 'walkthrough' " +
$"is the sphere passing around the cylinder, not through the slab.");
// The slab IS at sphere height — bottom should be below sphere top.
Assert.True(slabWorldZBottom < sphereTopZ,
$"Door slab bottom ({slabWorldZBottom:F3}) should be BELOW " +
$"player sphere top ({sphereTopZ:F3}). Slab Z range = " +
$"[{slabWorldZBottom:F3}, {slabWorldZTop:F3}]. Player sphere Z = " +
$"[{PlayerFootZ:F3}, {sphereTopZ:F3}]. The slab IS at " +
$"sphere height (overlap from {MathF.Max(slabWorldZBottom, PlayerFootZ):F3} " +
$"to {MathF.Min(slabWorldZTop, sphereTopZ):F3}). So the inside-out " +
$"walkthrough is NOT caused by the slab being above the sphere — " +
$"the bug must be in BSP polygon-level collision response.");
}
[Fact]