feat(core+app): Phase 6.1 — resolve idle motion frame from MotionTable

Walks each entity's Setup → MotionTable → Animation chain to get the
per-part frame for its default idle pose, then uses that frame in
SetupMesh.Flatten instead of the static PlacementFrames lookup. For
creatures and characters this should produce the upright "Resting"
pose (e.g. for the Nullified Statue of a Drudge) instead of the
Setup-default crouch.

This is a minimal Phase 6 cut: render the FIRST frame of the IDLE
motion as a static pose. No per-frame interpolation, no walking, no
attack motions, no transitions. Those are larger pieces tracked in
docs/plans/2026-04-11-roadmap.md under Phase 6.

Algorithm ported from references/ACViewer/.../Physics/Animation/
MotionTable.SetDefaultState:

  1. Look up Setup.DefaultMotionTable (0x09XXXXXX). 0 → no motion,
     fall back to PlacementFrames.
  2. MotionTable.StyleDefaults[DefaultStyle] → default substate.
  3. cycleKey = (DefaultStyle << 16) | (substate & 0xFFFFFF)
  4. MotionTable.Cycles[cycleKey] → MotionData.
  5. MotionData.Anims[0].AnimId → Animation dat.
  6. Animation.PartFrames[animData.LowFrame] → AnimationFrame
     containing the per-part transforms for the idle pose.

Added:
  - Core/Meshing/MotionResolver.cs: pure function GetIdleFrame(setup,
    dats) that walks the chain and returns an AnimationFrame or null.
    null is the "no motion data" sentinel and means caller should fall
    back to PlacementFrames.
  - SetupMesh.Flatten now takes an optional AnimationFrame override
    parameter. Pose source priority is:
      override → PlacementFrames[Resting] → PlacementFrames[Default]
    So existing call sites that don't pass an override get the Phase 5d
    Resting-fallback behavior unchanged. Static scenery is unaffected.
  - GameWindow.OnLiveEntitySpawned (live-mode hydrator) calls
    MotionResolver.GetIdleFrame and passes the result to Flatten.

Other Flatten call sites (offline scenery, interior EnvCells, scenery
generator) NOT yet wired — those use static dat hydration where the
entities don't have meaningful motion tables. The user-visible win
from this commit is in the live spawn pipeline only.

Things I'm not certain about and will check via the live run:
  - Whether Animation.PartFrames are in entity-root-relative space
    (matching PlacementFrames) or parent-relative (would need a parent
    walk we don't do). ACViewer's UpdateParts applies frames per-part
    without walking parents, suggesting root-relative — same convention
    as PlacementFrames.
  - Whether the resolver's null fallback is hit for creatures whose
    Setup.DefaultMotionTable happens to be 0 (would silently regress
    to Default placement). Worth checking if drudge looks the same.

Tests: 77 core + 83 net = 160, all green. No new tests yet because
the change is data-driven and best validated end-to-end via the live
run rather than synthetic dat fixtures (which would require fabricating
a complete MotionTable + Animation chain just to test the lookup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 18:36:59 +02:00
parent ac2af96b15
commit 090d265a4e
3 changed files with 126 additions and 16 deletions

View file

@ -621,7 +621,13 @@ public sealed class GameWindow : IDisposable
return;
}
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
// Phase 6: resolve the entity's idle motion frame from its
// MotionTable chain. For creatures and characters this gives us
// the upright "Resting" pose instead of the Setup's Default
// (T-pose / aggressive crouch). Static items with no motion table
// get null and fall back to PlacementFrames in Flatten.
var idleFrame = AcDream.Core.Meshing.MotionResolver.GetIdleFrame(setup, _dats);
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup, idleFrame);
// Apply the server's AnimPartChanges: "replace part at index N
// with GfxObj M". This is how characters become clothed (head →