From bb18614a89e3ee720ffbf44db61330cdeb44e00b Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Jul 2026 17:10:19 +0200 Subject: [PATCH] =?UTF-8?q?fix=20#175=20(take=202):=20the=20cycle=20lookup?= =?UTF-8?q?=20used=20the=20bare=20style=20key=20=E2=80=94=20silent=20no-op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/AcDream.App/Rendering/GameWindow.cs | 34 +++++----- .../Physics/Motion/MotionTablePose.cs | 65 +++++++++++++++++++ .../Issue175HubDoorPoseInspectionTests.cs | 35 ++++++++++ 3 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 src/AcDream.Core/Physics/Motion/MotionTablePose.cs 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()