diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index bdf00131..8c446c7d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4268,14 +4268,12 @@ public sealed class GameWindow : IDisposable } /// - /// #175: the motion table's default-state pose — frame LowFrame of the - /// default style's first cycle animation — the pose an idle entity's - /// parts hold (retail: CPartArray init runs the motion table's default - /// state; collision tests the resulting live CPhysicsPart poses). Used - /// as the BSP shadow-shape part-pose override at registration. Returns - /// null (→ placement-frame fallback) when the entity has no motion - /// table, the table has no default cycle, or the frame doesn't cover - /// parts. + /// #175: the motion table's default-state pose (the closed pose for + /// doors) — the derivation lives in + /// (Core, + /// dat-conformance-tested; the first cut here used a bare-style cycle + /// key, always missed, and silently no-oped — the "175 is not fixed" + /// report). Returns null → placement-frame fallback. /// private IReadOnlyList? MotionTableDefaultPose( uint motionTableId, int partCount) @@ -4285,17 +4283,19 @@ public sealed class GameWindow : IDisposable var mt = _dats.Get(motionTableId); if (mt is null) return null; - if (!mt.Cycles.TryGetValue((int)mt.DefaultStyle, out var cycle) - || cycle.Anims.Count == 0) - return null; + var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames( + mt, id => _dats.Get(id)); - var animRef = cycle.Anims[0]; - var anim = _dats.Get(animRef.AnimId); - if (anim is null || anim.PartFrames.Count == 0) return null; + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + { + string desc = pose is null ? "null->placement-fallback" + : System.FormattableString.Invariant( + $"part0=({pose[0].Origin.X:F2},{pose[0].Origin.Y:F2},{pose[0].Origin.Z:F2})"); + Console.WriteLine(System.FormattableString.Invariant( + $"[shape-pose] mt=0x{motionTableId:X8} parts={partCount} {desc}")); + } - int idx = System.Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1); - var frames = anim.PartFrames[idx].Frames; - return frames.Count >= partCount ? frames : null; + return pose; } /// diff --git a/src/AcDream.Core/Physics/Motion/MotionTablePose.cs b/src/AcDream.Core/Physics/Motion/MotionTablePose.cs new file mode 100644 index 00000000..6acddd33 --- /dev/null +++ b/src/AcDream.Core/Physics/Motion/MotionTablePose.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Physics.Motion; + +/// +/// #175: the motion table's DEFAULT-STATE part pose — the pose an idle +/// entity's parts hold (retail: CMotionTable::SetDefaultState +/// 0x005230a0 installs StyleDefaults[DefaultStyle]'s cycle; the parts +/// then sit at that animation's frames — the live CPhysicsPart pose +/// collision tests against). Used as the BSP shadow-shape part-pose override +/// at server-entity registration (doors: the CLOSED pose). +/// +/// +/// Cycle key arithmetic mirrors 's +/// LookupCycle (CMotionTable.cs:683): (style << 16) | +/// (substate & 0xFFFFFF) — the raw dat Cycles dictionary is +/// keyed by the COMBINED word, not the bare style (the first cut of this +/// helper looked up the bare style, always missed, and silently fell back +/// to placement frames — the #175 "not fixed" report). +/// +/// +public static class MotionTablePose +{ + /// + /// Resolve the default-state pose frames. Returns null (callers fall + /// back to placement frames) when the table has no default-style + /// substate, no matching cycle, or no animation. A pose covering FEWER + /// parts than the Setup is returned as-is — + /// falls back to the placement frame + /// PER PART beyond the override's length (e.g. a door anim that poses + /// only the panel parts, not the BSP-less frame header). + /// + /// The raw dat motion table (wire MotionTableId). + /// Animation loader (production: + /// id => dats.Get<Animation>(id)). + public static IReadOnlyList? DefaultStatePartFrames( + MotionTable mt, + Func loadAnimation) + { + if (mt is null) return null; + + // SetDefaultState: StyleDefaults[DefaultStyle] → the default substate. + if (!mt.StyleDefaults.TryGetValue(mt.DefaultStyle, out var defaultSubstateCmd)) + return null; + + // LookupCycle key (CMotionTable.cs:683 — same wrap semantics). + uint style = (uint)mt.DefaultStyle; + uint substate = (uint)defaultSubstateCmd; + int key = (int)((style << 16) | (substate & 0xFFFFFFu)); + + if (!mt.Cycles.TryGetValue(key, out var cycle) || cycle.Anims.Count == 0) + return null; + + var animRef = cycle.Anims[0]; + var anim = loadAnimation(animRef.AnimId); + if (anim is null || anim.PartFrames.Count == 0) return null; + + int idx = Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1); + var frames = anim.PartFrames[idx].Frames; + return frames.Count > 0 ? frames : null; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs index 2ec6134f..6cd9bf4f 100644 --- a/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs @@ -150,6 +150,41 @@ public class Issue175HubDoorPoseInspectionTests } } + /// + /// #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()