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;
}
}