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 post-fix inside-out attempts — door IS being queried. The
collision-detection mechanics produce the wrong response. 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 **Investigation status (corrected 2026-05-25 late evening).** Two new
post-fix (`Directional_OutsideIn_*`, `Directional_InsideOut_*`) BOTH directional tests + a geometric pin test all PASS:
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.
A geometric pin test (`Geometric_DoorSlabZRange_AbovePlayerSphereTop`) - `Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace` PASSES.
reveals the real story: - `Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace` PASSES.
- `Geometric_DoorSlabAtSphereHeight_OverlapsInZ` PASSES.
The geometric test reveals (correctly computed this time):
``` ```
Setup 0x020019FF (cottage door): Setup 0x020019FF (cottage door) PhysicsPolygons local AABB:
CylSphere[0]: r=0.10, h=0.20, origin=(0, 0, 0.018) min=(-0.954, -0.134, -1.236) max=(0.971, 0.127, 1.255)
→ world Z [94.118, 94.318] when entity at Z=94.1 (slab origin at GEOMETRIC CENTER, not the bottom)
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: partFrame[0].Origin = (-0.006, 0.125, 1.275) → lifts slab origin
sphere height = 1.20, sphere top = 95.20 1.275 m above entity Z
BSP slab BOTTOM (95.375) is ABOVE sphere TOP (95.20) by 0.175 m. With entity at world (132.6, 17.1, 94.1) + 180° entity rotation:
The slab NEVER collides with the player's body sphere. 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 slab IS at sphere height — it should collide.** Both directional
The door's only collision against a player at floor level is the tests prove BSP collision response is symmetric for sphere-to-slab
0.10 m radius foot cylinder. Sphere radius 0.48 + cyl 0.10 = 0.58 m approach. Yet production shows asymmetric inside-out walkthrough at
collision reach. Any sphere center > 0.58 m from cylinder center off-center positions. The bug must be in one of:
(132.6, 17.1) passes freely.
The user-reported "inside-out walkthrough at ~50 cm off-center" is 1. **The portal-reachable cells from indoor cell 0x0150 still miss the
the sphere walking AROUND the cylinder, at X = 132.6 ± 0.6+ m where door's shadow at certain sphere positions**, despite the
collision misses entirely. "Body partially intersects door" is the AddAllOutsideCells fix. The user's walkthrough at X=133.655 (1.05 m
character model occupying the visual door's volume while the collision east of door center) puts the sphere mostly east of slab X range
sphere passes beside the foot cylinder. [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 2. **The BSP polygon-level test for partial-overlap geometry.** Sphere
blocking — when the user approaches more centered or hits the cylinder half-east-of-slab, sphere south edge at slab north edge, moving +Y:
head-on. The cell-visibility fix made the cylinder visible from indoor sphere is on the verge of leaving the slab volume. BSPQuery's polygon
cells (it wasn't before), which is why outside-in went from "walks intersection might consider this a "leaving collision" with no
through" to "blocks" — but the cylinder is the actual collider. 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.** 3. **Cell BSP (cell 0x0150's PhysicsPolygons) is missing**. The doorway
Three candidate next-step investigations: 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 Three candidate investigations, ranked by ROI:
`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 **A. cdb attach to retail** at a Holtburg cottage doorway. Break on
handoff they are visual-only (HasPhysics=false). Verify by direct `CTransition::FindObjCollisions` for the door entity. Inspect what
dat read — maybe the PhysicsBSP is null but the cylinder/sphere shapes retail actually tests against. THIS IS DEFINITIVE — answers
list on the GfxObj itself has collision data we're missing. "what should we be doing differently" in 15-30 min. CLAUDE.md has the
`DoorSetupGfxObjInspectionTests` already prints this; re-read. toolchain ready.
3. **The cottage cell BSP encloses the doorway.** Cell 0x0150 **B. Reproduce inside-out walkthrough at unit-test speed.** Load real
(the doorway alcove) is bounded by cottage walls. The DOOR opens cell 0x0150 BSP into the harness (via CacheCellStruct from dat) +
through a gap in those walls. When the door is closed, the gap register door at faithful transform + replay captured tick 3262.
is filled by the door geometry. Maybe retail's collision relies If walkthrough reproduces at unit speed, can iterate on the fix in
on the cottage walls (cell BSP) for the "doorway side walls" and <500 ms.
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 **C. Audit GetNearbyObjects radial sweep + AddAllOutsideCells coverage**
through the doorway gap, walks past the foot cylinder (which is small), for east-neighbor cell when sphere XY is at primary cell boundary.
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 Recommendation: **A first** (cdb), then **B** to validate the fix at
ShadowShapeBuilder.FromSetup AND verifies setup.Radius is reflected unit-test speed.
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 ## Commits

View file

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