diff --git a/docs/research/2026-05-24-door-dat-inspection-findings.md b/docs/research/2026-05-24-door-dat-inspection-findings.md new file mode 100644 index 0000000..3028fa5 --- /dev/null +++ b/docs/research/2026-05-24-door-dat-inspection-findings.md @@ -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. +``` diff --git a/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs new file mode 100644 index 0000000..70e3f57 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs @@ -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; + +/// +/// 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. +/// +/// +/// 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. +/// +/// +public class DoorSetupGfxObjInspectionTests +{ + private readonly ITestOutputHelper _out; + public DoorSetupGfxObjInspectionTests(ITestOutputHelper output) => _out = output; + + private const uint DoorSetupId = 0x020019FFu; + + /// + /// 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. + /// + [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(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(gfxId); + if (gfx is null) + { + _out.WriteLine($" Get(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; + } +}