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

@ -4268,14 +4268,12 @@ public sealed class GameWindow : IDisposable
}
/// <summary>
/// #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
/// <paramref name="partCount"/> parts.
/// #175: the motion table's default-state pose (the closed pose for
/// doors) — the derivation lives in
/// <see cref="AcDream.Core.Physics.Motion.MotionTablePose"/> (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.
/// </summary>
private IReadOnlyList<DatReaderWriter.Types.Frame>? MotionTableDefaultPose(
uint motionTableId, int partCount)
@ -4285,17 +4283,19 @@ public sealed class GameWindow : IDisposable
var mt = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(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<DatReaderWriter.DBObjs.Animation>(id));
var animRef = cycle.Anims[0];
var anim = _dats.Get<DatReaderWriter.DBObjs.Animation>(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;
}
/// <summary>

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;
}
}

View file

@ -150,6 +150,41 @@ public class Issue175HubDoorPoseInspectionTests
}
}
/// <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 &lt;&lt; 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()