The shipped derivation looked up mt.Cycles[DefaultStyle]; the dat Cycles dictionary is keyed by the COMBINED (style << 16) | substate word (CMotionTable.cs:683), so the lookup always missed and the pose override silently fell back to placement frames — user re-test: "175 is not fixed". The pins covered the override plumbing but not the derivation, the one part with no offline fixture. Extract the derivation to Core as MotionTablePose.DefaultStatePartFrames using the retail SetDefaultState chain (StyleDefaults[DefaultStyle] -> combined-key LookupCycle, same wrap arithmetic as CMotionTable.cs:683) and pin it against the REAL dat (human MT 0x09000001 resolves a 34-part pose — this test fails on the old key math). Short poses now apply PER PART (ShadowShapeBuilder already falls back per index) so a door anim posing only the panels still overrides them while the BSP-less header keeps its placement frame. [shape-pose] diagnostic (ACDREAM_DUMP_MOTION) prints mt id + resolved part0 pose per BSP registration so live launches show the actual outcome. Suites: Core 2540 / App 713 / UI 425 / Net 385 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
265 lines
12 KiB
C#
265 lines
12 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 ===");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// #175 derivation conformance — REAL-DAT pin for
|
|
/// <see cref="AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames"/>.
|
|
/// The first cut of the derivation looked up <c>Cycles[DefaultStyle]</c>
|
|
/// with the bare style word; the dictionary is keyed by the COMBINED
|
|
/// <c>(style << 16) | substate</c> 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.
|
|
/// </summary>
|
|
[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<MotionTable>(0x09000001u);
|
|
Assert.NotNull(mt);
|
|
|
|
var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames(
|
|
mt!, id => dats.Get<Animation>(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;
|
|
}
|
|
|
|
/// <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
|
|
}
|
|
}
|