The Facility Hub double door (Setup 0x02000C9D) embeds the player into the visual panel from one side and blocks with a phantom slab on the other: its Setup PLACEMENT frames pose the two panels AJAR (yaw -150/-30 deg, 0.44 m behind the doorway plane — dat-confirmed by the Issue175 inspection) while the rendered door poses them CLOSED from the wire-supplied motion table via the sequencer. ShadowShapeBuilder read placement frames, so the 1.66x0.29x2.95 m physics slabs registered at the ajar pose. Retail tests each part's LIVE CPhysicsPart pose — for an idle door, the motion table's default (closed) state. Fix: ShadowShapeBuilder.FromSetup gains partPoseOverride (BSP part shapes only); RegisterServerEntityCollision derives it from the spawn MotionTableId via GameWindow.MotionTableDefaultPose (default style -> first cycle -> LowFrame part frames). Null/short poses fall back per-part to placement frames — table-less entities and landblock statics unchanged. One-shot snapshot vs retail's per-frame live pose is register row AP-84 (equivalent for the door lifecycle: closed == default pose; open == ETHEREAL bypasses collision, #150). Pins: FromSetup_PartPoseOverride_ReplacesPlacementFrames / _NoOverride_KeepsPlacementFrames / _ShortOverride_FallsBackPerPart. Suites: Core 2539 / App 713 / UI 425 / Net 385 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
230 lines
10 KiB
C#
230 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
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<Setup>(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<GfxObj>(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<MotionTable>(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<Animation>(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 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// With a motion-table pose override, the BSP part shapes must use it —
|
|
/// the closed pose, not the ajar placement pose (the #175 offset).
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Null override (no motion table) keeps the pre-#175 placement-frame
|
|
/// behavior — landblock statics and table-less entities unchanged.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A short override (fewer frames than parts) falls back to placement
|
|
/// frames — a mismatched motion table must not misplace collision.
|
|
/// </summary>
|
|
[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
|
|
}
|
|
}
|