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()