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

@ -14,24 +14,28 @@ public static class SetupMesh
/// Does NOT walk ParentIndex — each part's transform is local to the setup root.
/// This is simplification for Phase 2; complex hierarchical rigs are Phase 3.
/// </summary>
public static IReadOnlyList<MeshRef> Flatten(Setup setup)
public static IReadOnlyList<MeshRef> Flatten(Setup setup, AnimationFrame? motionFrameOverride = null)
{
// ACViewer's CreateMesh always calls SetPlacementFrame(0x65) =
// Placement.Resting after creating any mesh, regardless of object
// type. For creatures and characters this matters a lot — Default
// is the aggressive battle crouch pose with arms extended forward,
// and Resting is the upright idle pose. Without this preference,
// every drudge/skeleton/character renders mid-attack, including
// the Nullified Statue of a Drudge whose only placement frame is
// keyed by Resting.
// Pose source priority:
// 1. motionFrameOverride — caller has resolved an idle/animation
// frame from the entity's MotionTable (Phase 6). This is the
// best source for creatures and characters because their
// Setup.PlacementFrames don't define an upright idle pose.
// 2. Setup.PlacementFrames[Resting] — used by static objects
// that have a Resting frame defined (e.g. signs, doors).
// 3. Setup.PlacementFrames[Default] — fallback for everything
// else (most scenery), which is the only frame those setups
// define and renders correctly.
//
// Fall back to Default if Resting isn't present (most scenery
// setups only define Default). The user-visible difference is
// most dramatic for creatures; static dat scenery is unaffected.
AnimationFrame? defaultAnim = null;
if (setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting))
// Without an override, creatures used to render in their Default
// pose (T-pose-ish or aggressive crouch) because their MotionTable
// wasn't consulted. The Phase 6 MotionResolver provides the override
// by walking Setup.DefaultMotionTable → MotionTable.Cycles →
// Animation.PartFrames[LowFrame].
AnimationFrame? defaultAnim = motionFrameOverride;
if (defaultAnim is null && setup.PlacementFrames.TryGetValue(Placement.Resting, out var resting))
defaultAnim = resting;
else if (setup.PlacementFrames.TryGetValue(Placement.Default, out var af))
if (defaultAnim is null && setup.PlacementFrames.TryGetValue(Placement.Default, out var af))
defaultAnim = af;
var result = new List<MeshRef>(setup.Parts.Count);