using System; using System.IO; using System.Linq; using System.Numerics; using AcDream.Core.Physics; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Options; using DatReaderWriter.Types; using Xunit; using Xunit.Abstractions; using Env = System.Environment; using Placement = DatReaderWriter.Enums.Placement; namespace AcDream.Core.Tests.Physics; /// /// #175 (2026-07-05) — read-only dat inspection for the Facility Hub door /// (Setup 0x02000C9D, guid 0x78A020C7 in the live session). User report: /// the door's COLLISION sits displaced to the far side of the VISUAL panel /// (embed from one side deep enough to camera-clip; a phantom wall on the /// other side that can push the player out of use radius). /// /// Hypothesis under test: collision registers from the Setup's /// PlacementFrames (ShadowShapeBuilder.FromSetup — Resting|Default|first) /// while the rendered panel poses from the motion table's default/closed /// state through the sequencer; retail tests the part's LIVE pose /// (CPhysicsPart), so a door whose placement frame differs from its /// motion-table closed pose shows exactly this offset. This test dumps both /// poses so the divergence (or its absence) is a dat fact, not a theory. /// /// SKIP when the dat directory is absent (CI); local runs have it. /// public class Issue175HubDoorPoseInspectionTests { private readonly ITestOutputHelper _out; public Issue175HubDoorPoseInspectionTests(ITestOutputHelper output) => _out = output; private const uint HubDoorSetupId = 0x02000C9Du; [Fact] public void HubDoorSetup_PlacementVsMotionPose_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(HubDoorSetupId); Assert.NotNull(setup); _out.WriteLine($"=== Setup 0x{HubDoorSetupId:X8} ==="); _out.WriteLine($" Flags = {setup!.Flags} (0x{(uint)setup.Flags:X8})"); _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($" DefaultAnimation = 0x{setup.DefaultAnimation:X8}"); _out.WriteLine($" DefaultScript = 0x{setup.DefaultScript:X8}"); _out.WriteLine($" DefaultMotionTable = 0x{setup.DefaultMotionTable:X8}"); _out.WriteLine($" CylSpheres={setup.CylSpheres.Count} Spheres={setup.Spheres.Count} Radius={setup.Radius:F3}"); foreach (var c in setup.CylSpheres) _out.WriteLine($" cyl r={c.Radius:F3} h={c.Height:F3} origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3})"); _out.WriteLine($" PlacementFrames = {setup.PlacementFrames.Count}"); foreach (var kv in setup.PlacementFrames) { _out.WriteLine($" [{kv.Key}] frames={kv.Value.Frames.Count}"); for (int i = 0; i < kv.Value.Frames.Count; i++) { var f = kv.Value.Frames[i]; _out.WriteLine( $" part[{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})"); } } // Part 0's physics BSP bounds — where the slab actually is in // PART-LOCAL space (composed with the poses above for world). foreach (uint gfxId in setup.Parts.Distinct()) { var gfx = dats.Get(gfxId); _out.WriteLine($"=== GfxObj 0x{gfxId:X8} ==="); if (gfx is null) { _out.WriteLine(" NULL"); continue; } var root = gfx.PhysicsBSP?.Root; _out.WriteLine($" PhysicsBSP.Root = {(root is null ? "NULL" : "non-null")}"); if (root?.BoundingSphere is { } bs) _out.WriteLine($" BSP bounds = ({bs.Origin.X:F3},{bs.Origin.Y:F3},{bs.Origin.Z:F3}) r={bs.Radius:F3}"); if (gfx.PhysicsPolygons is { } pp && gfx.VertexArray?.Vertices is { } verts) { float minX = float.MaxValue, maxX = float.MinValue; float minY = float.MaxValue, maxY = float.MinValue; float minZ = float.MaxValue, maxZ = float.MinValue; foreach (var poly in pp.Values) foreach (var vid in poly.VertexIds) { if (!verts.TryGetValue((ushort)vid, out var sv)) continue; minX = Math.Min(minX, sv.Origin.X); maxX = Math.Max(maxX, sv.Origin.X); minY = Math.Min(minY, sv.Origin.Y); maxY = Math.Max(maxY, sv.Origin.Y); minZ = Math.Min(minZ, sv.Origin.Z); maxZ = Math.Max(maxZ, sv.Origin.Z); } _out.WriteLine($" Physics AABB (part-local) = X[{minX:F3},{maxX:F3}] Y[{minY:F3},{maxY:F3}] Z[{minZ:F3},{maxZ:F3}]"); } } // The motion-table default (closed) pose, if the setup names one: // frame 0 of the default style's default cycle — what the sequencer // renders for an idle closed door. if (setup.DefaultMotionTable != 0) { var mt = dats.Get(setup.DefaultMotionTable); _out.WriteLine($"=== MotionTable 0x{setup.DefaultMotionTable:X8} ==="); if (mt is null) { _out.WriteLine(" NULL"); return; } _out.WriteLine($" DefaultStyle = 0x{(uint)mt.DefaultStyle:X8}"); if (mt.Cycles.TryGetValue((int)mt.DefaultStyle, out var defCycle) && defCycle.Anims.Count > 0) { var animRef = defCycle.Anims[0]; _out.WriteLine($" default cycle anim[0] id=0x{animRef.AnimId:X8} lo={animRef.LowFrame} hi={animRef.HighFrame}"); var anim = dats.Get(animRef.AnimId); if (anim is not null && anim.PartFrames.Count > 0) { var f0 = anim.PartFrames[Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1)]; for (int i = 0; i < f0.Frames.Count; i++) { var f = f0.Frames[i]; _out.WriteLine( $" anim frame0 part[{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})"); } } else { _out.WriteLine(" anim NULL or no PartFrames"); } } else { _out.WriteLine(" no default-style cycle"); } } else { _out.WriteLine("=== no DefaultMotionTable on the setup ==="); } } /// /// #175 derivation conformance — REAL-DAT pin for /// . /// The first cut of the derivation looked up Cycles[DefaultStyle] /// with the bare style word; the dictionary is keyed by the COMBINED /// (style << 16) | substate word (CMotionTable.cs:683), so it /// always missed and the #175 fix silently no-oped. This pin loads the /// human motion table (0x09000001 — guaranteed present, default state /// NonCombat/Ready) and asserts the derivation actually resolves a pose. /// [Fact] public void MotionTablePose_DefaultState_ResolvesOnRealTable() { 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 mt = dats.Get(0x09000001u); Assert.NotNull(mt); var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames( mt!, id => dats.Get(id)); Assert.NotNull(pose); _out.WriteLine($"human MT default pose parts={pose!.Count} " + $"part0=({pose[0].Origin.X:F3},{pose[0].Origin.Y:F3},{pose[0].Origin.Z:F3})"); Assert.True(pose.Count >= 1); } // ── #175 fix pins: ShadowShapeBuilder partPoseOverride ────────────── private static Setup MakeTwoPartSetup() { var setup = new Setup(); setup.Parts.Add(0x01000001u); setup.Parts.Add(0x01000002u); var placement = new AnimationFrame(2); placement.Frames.Clear(); placement.Frames.Add(new Frame { Origin = new Vector3(0.88f, -0.44f, 1.37f), Orientation = new Quaternion(0f, 0f, -0.966f, 0.259f) }); placement.Frames.Add(new Frame { Origin = new Vector3(-0.88f, -0.44f, 1.37f), Orientation = new Quaternion(0f, 0f, -0.259f, 0.966f) }); setup.PlacementFrames[Placement.Default] = placement; return setup; } /// /// With a motion-table pose override, the BSP part shapes must use it — /// the closed pose, not the ajar placement pose (the #175 offset). /// [Fact] public void FromSetup_PartPoseOverride_ReplacesPlacementFrames() { var setup = MakeTwoPartSetup(); var closed = new[] { new Frame { Origin = new Vector3(0.85f, 0f, 1.37f), Orientation = Quaternion.Identity }, new Frame { Origin = new Vector3(-0.85f, 0f, 1.37f), Orientation = Quaternion.Identity }, }; var shapes = ShadowShapeBuilder.FromSetup( setup, entScale: 1f, hasPhysicsBsp: _ => true, partPoseOverride: closed); Assert.Equal(2, shapes.Count); Assert.Equal(new Vector3(0.85f, 0f, 1.37f), shapes[0].LocalPosition); Assert.Equal(Quaternion.Identity, shapes[0].LocalRotation); Assert.Equal(new Vector3(-0.85f, 0f, 1.37f), shapes[1].LocalPosition); } /// /// Null override (no motion table) keeps the pre-#175 placement-frame /// behavior — landblock statics and table-less entities unchanged. /// [Fact] public void FromSetup_NoOverride_KeepsPlacementFrames() { var setup = MakeTwoPartSetup(); var shapes = ShadowShapeBuilder.FromSetup( setup, entScale: 1f, hasPhysicsBsp: _ => true); Assert.Equal(2, shapes.Count); Assert.Equal(new Vector3(0.88f, -0.44f, 1.37f), shapes[0].LocalPosition); Assert.Equal(new Quaternion(0f, 0f, -0.966f, 0.259f), shapes[0].LocalRotation); } /// /// A short override (fewer frames than parts) falls back to placement /// frames — a mismatched motion table must not misplace collision. /// [Fact] public void FromSetup_ShortOverride_FallsBackPerPart() { var setup = MakeTwoPartSetup(); var shortOverride = new[] { new Frame { Origin = new Vector3(0.85f, 0f, 1.37f), Orientation = Quaternion.Identity }, }; var shapes = ShadowShapeBuilder.FromSetup( setup, entScale: 1f, hasPhysicsBsp: _ => true, partPoseOverride: shortOverride); Assert.Equal(2, shapes.Count); Assert.Equal(new Vector3(0.85f, 0f, 1.37f), shapes[0].LocalPosition); // override Assert.Equal(new Vector3(-0.88f, -0.44f, 1.37f), shapes[1].LocalPosition); // placement fallback } }