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>
221 lines
10 KiB
C#
221 lines
10 KiB
C#
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;
|
|
}
|
|
}
|