fix #175 (take 2): the cycle lookup used the bare style key — silent no-op

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>
This commit is contained in:
Erik 2026-07-05 17:10:19 +02:00
parent 355e389d4c
commit bb18614a89
3 changed files with 117 additions and 17 deletions

View file

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// #175: the motion table's DEFAULT-STATE part pose — the pose an idle
/// entity's parts hold (retail: <c>CMotionTable::SetDefaultState</c>
/// 0x005230a0 installs <c>StyleDefaults[DefaultStyle]</c>'s cycle; the parts
/// then sit at that animation's frames — the live <c>CPhysicsPart</c> pose
/// collision tests against). Used as the BSP shadow-shape part-pose override
/// at server-entity registration (doors: the CLOSED pose).
///
/// <para>
/// Cycle key arithmetic mirrors <see cref="CMotionTable"/>'s
/// <c>LookupCycle</c> (CMotionTable.cs:683): <c>(style &lt;&lt; 16) |
/// (substate &amp; 0xFFFFFF)</c> — the raw dat <c>Cycles</c> 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).
/// </para>
/// </summary>
public static class MotionTablePose
{
/// <summary>
/// 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 —
/// <see cref="ShadowShapeBuilder"/> 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).
/// </summary>
/// <param name="mt">The raw dat motion table (wire MotionTableId).</param>
/// <param name="loadAnimation">Animation loader (production:
/// <c>id => dats.Get&lt;Animation&gt;(id)</c>).</param>
public static IReadOnlyList<Frame>? DefaultStatePartFrames(
MotionTable mt,
Func<uint, Animation?> 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;
}
}