test(phys): door setup + GfxObj dat-inspection — Hypothesis A falsified

Read-only deterministic test that opens the real client dat and
dumps Setup 0x020019FF + every GfxObj id it references. Bypasses
PhysicsDataCache's four early-return filters so we see WHAT is in
the dat, not what got into the cache. Skips gracefully when the
dat directory isn't present (keeps CI green).

Result reframes the prior session's investigation:

GfxObj 0x010044B5 (part 0 of the door) DOES have a full door-slab
PhysicsBSP — 6 two-sided (SidesType=Landblock) polygons forming a
1.925m × 0.261m × 2.490m collision volume at frame[0] offset
(-0.006, 0.125, 1.275). Bounding sphere radius 1.975. HasPhysics
flag set. So the handoff's Hypothesis A ("0x010044B5 has no
collision-bearing polygons, only visual") is FALSE.

GfxObj 0x010044B6 (parts 1 + 2, the swinging leaves) IS visual-only
by retail design — HasPhysics clear, PhysicsBSP null, 0 PhysicsPolygons,
but 87 visual Polygons. Our ShadowShapeBuilder skipping these matches
retail's CPhysicsPart::find_obj_collisions short-circuit on
physics_bsp==0 (acclient_2013_pseudo_c.txt:275051) — not a bug.

So the door collision bug is in INTEGRATION, not data. The Task 7
experiment last session registered 0x010044B5's BSP but got zero
[resolve-bldg] attributions. With the data confirmed good, the
next apparatus is a deterministic harness that hydrates 0x010044B5
from a dat dump, registers it via RegisterMultiPart, and sweeps a
player sphere into the door to confirm whether BSP collision fires
in isolation.

Pickup prompt + full reading in
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:34:41 +02:00
parent c89df8e4c0
commit e1d94d7094
2 changed files with 479 additions and 0 deletions

View file

@ -0,0 +1,258 @@
# Door collision dat inspection — findings
**Date:** 2026-05-24 (evening, continuation of door collision investigation)
**Branch:** `claude/strange-albattani-3fc83c`
**Status:** Evidence gathered. Hypothesis A from
[`2026-05-24-door-collision-session-handoff.md`](2026-05-24-door-collision-session-handoff.md) **FALSIFIED**.
---
## TL;DR
A deterministic, read-only dat-inspection test
([`DoorSetupGfxObjInspectionTests.cs`](../../tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs))
opens the real client dat and prints the raw state of door Setup
`0x020019FF` + its three referenced GfxObjs.
**Result — Hypothesis A is wrong.** Part 0 (`0x010044B5`) has a complete
1.925 m × 0.261 m × 2.490 m door-sized collision volume in the dat. Six
two-sided (`SidesType=Landblock`) physics polygons form the closed door
slab. Bounding sphere radius 1.975 m. Setup `Flags=HasPhysicsBSP`.
Parts 1, 2 (`0x010044B6`) **are** visual-only by design — `HasPhysics`
flag is clear, `PhysicsBSP` is null, `PhysicsPolygons.Count = 0`. **This
matches retail's `CPhysicsPart::find_obj_collisions`**
([`acclient_2013_pseudo_c.txt:275051`](../research/named-retail/acclient_2013_pseudo_c.txt)),
which explicitly short-circuits when `physics_bsp == 0`. So retail also
runs no collision against `0x010044B6` — and our skip-on-null-BSP
behavior is retail-faithful, not a bug.
**This rewrites the "next-session approach" recommendation in the prior
handoff.** The handoff said "if 0x010044B5's BSP has zero floor-touching
polys → Hypothesis A confirmed → pivot strategy." The BSP has six
collision polygons forming the whole door slab. The pivot is not needed;
we need to figure out why our integration of `0x010044B5`'s BSP didn't
fire during the Task 7 experiment.
---
## Raw dump (verbatim from the test)
```
=== Setup 0x020019FF ===
Flags = HasParent, AllowFreeHeading, HasPhysicsBSP (0x0000000D)
Radius = 0.1414
Height = 0.2000
StepUp = 0.0900
StepDown = 0.0900
CylSpheres = 0
Spheres = 1
[0] r=0.1000 origin=(0.000,0.000,0.018)
Parts = 3
[0] gfxObj=0x010044B5
[1] gfxObj=0x010044B6
[2] gfxObj=0x010044B6
PlacementFrames = 1
[Default] frameCount=3
frame[0] pos=(-0.006,0.125,1.275) rot=(0.000,0.000,0.000,1.000)
frame[1] pos=(0.710,0.000,1.210) rot=(0.000,0.000,0.000,1.000)
frame[2] pos=(0.710,0.247,1.210) rot=(0.000,0.000,1.000,0.000)
=== GfxObj 0x010044B5 === (the door slab — has physics)
Flags = HasPhysics, HasDrawing, HasDIDDegrade (0x0000000B)
HasPhysics = True
VertexArray = non-null, 8 vertices
PhysicsPolygons = 6 polys
PhysicsBSP = non-null
PhysicsBSP.Root = non-null
Root.Type = BPnN
Root.BoundingSphere = (-0.390,-0.056,-0.150) r=1.975
BSP tree total polys (including children) = 6
PhysicsPolygon AABB sweep (first 6 polys):
[0x0000] nVerts=4 sides=Landblock min=(-0.954,-0.134,-1.236) max=(0.971,0.127,-1.236) # bottom face
[0x0001] nVerts=4 sides=Landblock min=(-0.954,-0.134,-1.236) max=(-0.954,0.127,1.255) # left face
[0x0002] nVerts=4 sides=Landblock min=(-0.954,-0.134, 1.255) max=(0.971,0.127,1.255) # top face
[0x0003] nVerts=4 sides=Landblock min=( 0.971,-0.134,-1.236) max=(0.971,0.127,1.255) # right face
[0x0004] nVerts=4 sides=Landblock min=(-0.954,-0.134,-1.236) max=(0.971,-0.134,1.255) # front face
[0x0005] nVerts=4 sides=Landblock min=(-0.954, 0.127,-1.236) max=(0.971,0.127,1.255) # back face
PhysicsPolygons combined AABB: min=(-0.954,-0.134,-1.236) max=(0.971,0.127,1.255)
size=(1.925, 0.261, 2.490)
=== GfxObj 0x010044B6 === (the leaves — visual-only by design)
Flags = HasDrawing, HasDIDDegrade (0x0000000A)
HasPhysics = False
VertexArray = non-null, 40 vertices
PhysicsPolygons = 0 polys
PhysicsBSP = NULL
Polygons (visual) = 87 polys
DrawingBSP = non-null
```
---
## What this means
### The data is right
Part 0's BSP is a six-faced thin slab oriented as a vertical door:
1.925 m wide × 0.261 m thin × 2.490 m tall. Placed at frame[0] offset
`(-0.006, 0.125, 1.275)`, it occupies entity-local Z ∈ `[0.039, 2.530]`
a standard door height. All six faces are
`SidesType=Landblock` (two-sided collision — catches a sphere
approaching from either side).
This is exactly what retail's collision system uses to block doors.
No mystery, no missing data, no need to fall back to a wider Cylinder
approximation.
### The leaves are correctly visual-only
`0x010044B6` is the swinging door leaf (used twice — left + right
panels). It has no physics by retail design. Our `ShadowShapeBuilder`
skipping these parts matches both the dat and retail's
`CPhysicsPart::find_obj_collisions`.
### So the bug is in integration, not data
The previous session's Task 7 experiment registered `0x010044B5`'s BSP
correctly (we saw `type=BSP gfxObj=0x010044B5 radius=2.000
localPos=(-0.006,0.125,1.275)` in the per-shape registration), yet got
**zero `[resolve-bldg]` attributions** during live play. With the data
now confirmed good, that gap must be in:
1. **The BSP collision dispatch never enters for the door entry**
`TransitionTypes.cs:2257` silently `continue`s when
`engine.DataCache.GetGfxObj(obj.GfxObjId)?.BSP?.Root is null`. If the
GfxObj wasn't cached at collision time (race with renderer load), the
entry is invisibly skipped. **No log distinguishes this from
"queried-and-no-hit."**
2. **Broadphase placeholder radius** — Task 2's `ShadowShapeBuilder`
uses `bspRadius = 2f` as a stand-in pending a Task 5/6 caller
replacement. The real dat value is `1.975` — close enough not to be
the blocker, but the placeholder convention means callers MUST
substitute the real BS radius from `cache.GetGfxObj(gfxId).BoundingSphere.Radius`
before registering. If a future caller forgets, the broadphase will
still mostly work but won't be tight.
3. **The broadphase center is the part's FRAME origin, not the BSP's
bounding-sphere center.** Frame origin = `(-0.006, 0.125, 1.275)`;
BS center in part-local = `(-0.390, -0.056, -0.150)`. Distance:
1.48 m. The 2.0 m broadphase radius nominally covers the BS sphere
(radius 1.975) from the frame origin only on the side closest to the
BS center. For approaches on the opposite side, the broadphase
sphere extends 2.0 m + 1.48 m = 3.48 m from the BS center — wider
than needed, but never too tight in the door case. Still, a more
faithful encoding centers the broadphase on the part's BS center +
frame offset, with radius = BS radius.
4. **BSPQuery against `SidesType=Landblock` polys**`BSPQuery.cs`
pass-through-copies `SidesType` (line 2277) but doesn't filter on
it. We have not yet verified that `Landblock`-typed polys actually
produce collision hits in our query pipeline against a thin-slab
geometry. (Note: indoor cells use `SidesType=Single`-typed cell-floor
polys and those work — the cellar replay tests pin that. But Doors'
`Landblock` polys may behave differently — particularly w.r.t.
two-sided collision.)
5. **Entity rotation at the doorway** — Holtburg cottage doors face
non-cardinal directions. The entity's world rotation
`entity_rot` composes with `frame[0].Rotation` (identity for part 0)
to produce `obj.Rotation = entity_rot`. The sphere
transform `inv(entity_rot) * (sphere_world obj.Position)` is
sensitive to that rotation. If we register with identity (forgetting
to plumb the spawn's rotation through), the BSP polys will be
oriented "into the world" wrong — passing tests that approach from
the wrong axis.
---
## Recommended next step
The handoff's "DO NOT speculate-and-fix again" rule still applies. The
right next move is **apparatus-first**, not another implementation
attempt:
**Write a focused unit test** that:
1. Loads the real `0x010044B5` PhysicsBSP from the dat via the
inspection test's pattern (or use `GfxObjDumpSerializer.Hydrate`
for a deterministic fixture).
2. Constructs a synthetic door entity at a known world position
`(132.6, 17.1, 94.08)` with a known rotation (try identity AND a
~90° Z rotation to cover both axes).
3. Sweeps a player sphere at the door from each of the four
compass directions, at off-center positions (50 cm off-center)
AND dead center.
4. Calls `Transition.FindObjCollisions` / `ResolveWithTransition`
directly (apparatus path mirrors the live one).
5. Asserts:
- Dead-center approach → `Collided` / `Adjusted` / `Slid`
with `CollideObjectGuids` containing the door entity.
- 50 cm off-center approach → same.
- From inside walking out → same.
If the test fails: we have a deterministic reproduction of the live
bug in <500 ms, and we can fix the integration with confidence.
If the test passes: the door bug is elsewhere (cell registration,
spawn-time race, etc.).
This is the next apparatus the previous session was building toward
when it ran out of cycles. With the data question now closed by the
dat inspection, it's the highest-information next move.
---
## What's in the tree right now
```
$ git status --short
?? docs/research/2026-05-24-door-dat-inspection-findings.md
?? tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs
[+ untracked launch logs from prior sessions]
```
Build green; existing tests still pass; new test runs in 34 ms and
produces the dump above.
---
## Pickup prompt for next session
```
Door collision dat inspection (2026-05-24 evening) FALSIFIED
Hypothesis A. Part 0 (0x010044B5) has a full door-slab BSP in the
dat — 6 Landblock-typed polys forming a 1.925 m × 0.261 m × 2.490 m
collision volume. Parts 1, 2 (0x010044B6) are visual-only by retail
design (HasPhysics flag clear). Retail and acdream both skip those
in CPhysicsPart::find_obj_collisions — that's not a bug.
Read docs/research/2026-05-24-door-dat-inspection-findings.md
State both altitudes:
Currently working toward: M1.5 — Indoor world feels right
Current phase: A6.P4 — door collision investigation continues.
Per-part BSP infrastructure (Tasks 1-6) ships
already; data is confirmed good in the dat; need
to determine WHY our integration of 0x010044B5's
BSP didn't fire collisions during the Task 7
experiment.
Next moves (in order):
1. Write CellarUpTrajectoryReplay-style apparatus test that
loads 0x010044B5's PhysicsBSP from a dat dump, registers a
synthetic door via RegisterMultiPart, and sweeps a player
sphere at it. Confirm BSP collision fires (or doesn't) in
isolation.
2. If the test passes → bug is in live registration (likely
cell scoping, entity rotation, or race with renderer
loading). Investigate live cell membership for door
entities.
3. If the test fails → bug is in BSPQuery.FindCollisions
against thin-slab Landblock-typed polys. Investigate the
6-path dispatcher for that case.
DO NOT re-attempt Task 7 (per-part BSP wiring in
RegisterLiveEntityCollision) until the apparatus test confirms
the BSP works in isolation. Tasks 1-6 stay; they're correct.
```

View file

@ -0,0 +1,221 @@
using System;
using System.IO;
using System.Linq;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// A6.P4 door fix (2026-05-24) — read-only dat inspection. Opens
/// the real client dat collection and reports the raw state of door
/// Setup 0x020019FF + its three referenced GfxObjs (0x010044B5 and
/// 0x010044B6 used twice). Answers the question "does the leaf GfxObj
/// 0x010044B6 actually have a non-null PhysicsBSP in the dat, or is it
/// genuinely visual-only?" without going through PhysicsDataCache's
/// four early-return filters, and without launching the live client.
///
/// <para>
/// SKIP if ACDREAM_DAT_DIR (or the default
/// %USERPROFILE%\Documents\Asheron's Call directory) isn't present —
/// keeps CI green. Local developer runs always have it.
/// </para>
/// </summary>
public class DoorSetupGfxObjInspectionTests
{
private readonly ITestOutputHelper _out;
public DoorSetupGfxObjInspectionTests(ITestOutputHelper output) => _out = output;
private const uint DoorSetupId = 0x020019FFu;
/// <summary>
/// Read door setup 0x020019FF and every GfxObj id it references.
/// Emits a structured report so we can decide whether the "BSP=null
/// on 0x010044B6" handoff finding is a data fact or a load-order /
/// loader-bug artifact.
/// </summary>
[Fact]
public void DoorSetup_AndParts_DatInspection()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir))
{
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var setup = dats.Get<Setup>(DoorSetupId);
Assert.NotNull(setup);
_out.WriteLine($"=== Setup 0x{DoorSetupId:X8} ===");
_out.WriteLine($" Flags = {setup!.Flags} (0x{(uint)setup.Flags:X8})");
_out.WriteLine($" Radius = {setup.Radius:F4}");
_out.WriteLine($" Height = {setup.Height:F4}");
_out.WriteLine($" StepUp = {setup.StepUpHeight:F4}");
_out.WriteLine($" StepDown = {setup.StepDownHeight:F4}");
_out.WriteLine($" CylSpheres = {setup.CylSpheres.Count}");
for (int i = 0; i < setup.CylSpheres.Count; i++)
{
var c = setup.CylSpheres[i];
_out.WriteLine($" [{i}] r={c.Radius:F4} h={c.Height:F4} origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3})");
}
_out.WriteLine($" Spheres = {setup.Spheres.Count}");
for (int i = 0; i < setup.Spheres.Count; i++)
{
var s = setup.Spheres[i];
_out.WriteLine($" [{i}] r={s.Radius:F4} origin=({s.Origin.X:F3},{s.Origin.Y:F3},{s.Origin.Z:F3})");
}
_out.WriteLine($" Parts = {setup.Parts.Count}");
for (int i = 0; i < setup.Parts.Count; i++)
{
_out.WriteLine($" [{i}] gfxObj=0x{setup.Parts[i]:X8}");
}
_out.WriteLine($" PlacementFrames = {setup.PlacementFrames.Count}");
foreach (var kv in setup.PlacementFrames)
{
_out.WriteLine($" [{kv.Key}] frameCount={kv.Value.Frames.Count}");
for (int i = 0; i < kv.Value.Frames.Count; i++)
{
var f = kv.Value.Frames[i];
_out.WriteLine($" frame[{i}] pos=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) " +
$"rot=({f.Orientation.X:F3},{f.Orientation.Y:F3},{f.Orientation.Z:F3},{f.Orientation.W:F3})");
}
}
_out.WriteLine($" HoldingLocations = {setup.HoldingLocations.Count}");
// Inspect each unique part GfxObj.
var uniqueGfxIds = setup.Parts.Distinct().ToList();
foreach (uint gfxId in uniqueGfxIds)
{
_out.WriteLine("");
InspectGfxObj(dats, gfxId);
}
_out.WriteLine("");
_out.WriteLine("=== Cache predicate would skip a part if: ===");
_out.WriteLine(" !Flags.HasFlag(HasPhysics) OR PhysicsBSP?.Root is null OR VertexArray is null");
_out.WriteLine("(See src/AcDream.Core/Physics/PhysicsDataCache.cs:44-47)");
}
private void InspectGfxObj(DatCollection dats, uint gfxId)
{
_out.WriteLine($"=== GfxObj 0x{gfxId:X8} ===");
var gfx = dats.Get<GfxObj>(gfxId);
if (gfx is null)
{
_out.WriteLine($" Get<GfxObj>(0x{gfxId:X8}) returned NULL — dat reader couldn't load");
return;
}
_out.WriteLine($" Flags = {gfx.Flags} (0x{(uint)gfx.Flags:X8})");
_out.WriteLine($" HasPhysics = {gfx.Flags.HasFlag(GfxObjFlags.HasPhysics)}");
_out.WriteLine($" HasDIDDegrade = {gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade)}");
_out.WriteLine($" VertexArray = {(gfx.VertexArray is null ? "NULL" : $"non-null, {gfx.VertexArray.Vertices?.Count ?? 0} vertices")}");
_out.WriteLine($" PhysicsPolygons = {(gfx.PhysicsPolygons is null ? "NULL" : $"{gfx.PhysicsPolygons.Count} polys")}");
_out.WriteLine($" PhysicsBSP = {(gfx.PhysicsBSP is null ? "NULL" : "non-null")}");
if (gfx.PhysicsBSP is not null)
{
_out.WriteLine($" PhysicsBSP.Root = {(gfx.PhysicsBSP.Root is null ? "NULL" : "non-null")}");
if (gfx.PhysicsBSP.Root is { } root)
{
_out.WriteLine($" Root.Type = {root.Type}");
_out.WriteLine($" Root.Polygons = {root.Polygons?.Count ?? 0}");
_out.WriteLine($" Root.PosNode = {(root.PosNode is null ? "null" : "non-null")}");
_out.WriteLine($" Root.NegNode = {(root.NegNode is null ? "null" : "non-null")}");
var bs = root.BoundingSphere;
if (bs is null)
_out.WriteLine(" Root.BoundingSphere = null");
else
_out.WriteLine($" Root.BoundingSphere = ({bs.Origin.X:F3},{bs.Origin.Y:F3},{bs.Origin.Z:F3}) r={bs.Radius:F3}");
// Walk the tree to count total polygons.
int totalPolys = CountTotalPolys(root);
_out.WriteLine($" BSP tree total polys (including children) = {totalPolys}");
}
}
_out.WriteLine($" Polygons (visual) = {(gfx.Polygons is null ? "NULL" : $"{gfx.Polygons.Count} polys")}");
if (gfx.DrawingBSP is null)
{
_out.WriteLine($" DrawingBSP = NULL");
}
else
{
_out.WriteLine($" DrawingBSP = non-null");
_out.WriteLine($" DrawingBSP.Root = {(gfx.DrawingBSP.Root is null ? "NULL" : "non-null")}");
}
// If physics data is present, dump first few polygon AABBs in local frame
// — these tell us WHERE collision-bearing geometry sits relative to the
// door entity origin (helps distinguish "frame top above doorway" vs
// "leaf spanning the threshold").
if (gfx.PhysicsPolygons is { Count: > 0 } polys
&& gfx.VertexArray?.Vertices is { } verts && verts.Count > 0)
{
_out.WriteLine($" PhysicsPolygon AABB sweep (first 6 polys):");
int dumped = 0;
foreach (var (pid, p) in polys)
{
if (dumped++ >= 6) break;
float minX = float.MaxValue, minY = float.MaxValue, minZ = float.MaxValue;
float maxX = float.MinValue, maxY = float.MinValue, maxZ = float.MinValue;
int nVerts = p.VertexIds.Count;
for (int i = 0; i < nVerts; i++)
{
if (!verts.TryGetValue((ushort)p.VertexIds[i], out var sv)) { nVerts = -1; break; }
var v = sv.Origin;
if (v.X < minX) minX = v.X;
if (v.Y < minY) minY = v.Y;
if (v.Z < minZ) minZ = v.Z;
if (v.X > maxX) maxX = v.X;
if (v.Y > maxY) maxY = v.Y;
if (v.Z > maxZ) maxZ = v.Z;
}
if (nVerts < 0) { _out.WriteLine($" [0x{pid:X4}] vertex lookup failed"); continue; }
_out.WriteLine($" [0x{pid:X4}] nVerts={nVerts} sides={p.SidesType} " +
$"min=({minX:F3},{minY:F3},{minZ:F3}) max=({maxX:F3},{maxY:F3},{maxZ:F3})");
}
if (polys.Count > 6)
_out.WriteLine($" ... ({polys.Count - 6} more)");
// Total physics polygon AABB
float gMinX = float.MaxValue, gMinY = float.MaxValue, gMinZ = float.MaxValue;
float gMaxX = float.MinValue, gMaxY = float.MinValue, gMaxZ = float.MinValue;
foreach (var (_, p) in polys)
{
int nVerts = p.VertexIds.Count;
for (int i = 0; i < nVerts; i++)
{
if (!verts.TryGetValue((ushort)p.VertexIds[i], out var sv)) continue;
var v = sv.Origin;
if (v.X < gMinX) gMinX = v.X;
if (v.Y < gMinY) gMinY = v.Y;
if (v.Z < gMinZ) gMinZ = v.Z;
if (v.X > gMaxX) gMaxX = v.X;
if (v.Y > gMaxY) gMaxY = v.Y;
if (v.Z > gMaxZ) gMaxZ = v.Z;
}
}
_out.WriteLine($" PhysicsPolygons combined AABB: min=({gMinX:F3},{gMinY:F3},{gMinZ:F3}) max=({gMaxX:F3},{gMaxY:F3},{gMaxZ:F3})");
_out.WriteLine($" size=({gMaxX - gMinX:F3},{gMaxY - gMinY:F3},{gMaxZ - gMinZ:F3})");
}
}
private static int CountTotalPolys(DatReaderWriter.Types.PhysicsBSPNode node)
{
int n = node.Polygons?.Count ?? 0;
if (node.PosNode is not null) n += CountTotalPolys(node.PosNode);
if (node.NegNode is not null) n += CountTotalPolys(node.NegNode);
return n;
}
}