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:
parent
ac2af96b15
commit
090d265a4e
3 changed files with 126 additions and 16 deletions
|
|
@ -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 →
|
||||
|
|
|
|||
100
src/AcDream.Core/Meshing/MotionResolver.cs
Normal file
100
src/AcDream.Core/Meshing/MotionResolver.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Meshing;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an entity's idle / default-state per-part animation frame from
|
||||
/// its Setup → MotionTable → Animation chain. Used by the rendering
|
||||
/// hydration path so creatures and characters render in their proper idle
|
||||
/// pose instead of the static <c>Setup.PlacementFrames[Default]</c> pose
|
||||
/// (which for drudges and most creatures is "T-pose-ish" or aggressive
|
||||
/// crouch — the wrong starting point for a calm idle).
|
||||
///
|
||||
/// <para>
|
||||
/// The resolution algorithm matches ACViewer's
|
||||
/// <c>Physics/Animation/MotionTable.SetDefaultState</c> for the static
|
||||
/// (non-animated) case: pick the first frame of the default cycle's
|
||||
/// first AnimData. We don't run the animation forward over time yet —
|
||||
/// the goal here is "render the FIRST frame of the IDLE motion" which
|
||||
/// gives us a sensible static pose.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// References (per CLAUDE.md cross-reference rule):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>references/ACViewer/.../Physics/Animation/MotionTable.cs::SetDefaultState</c>
|
||||
/// — the algorithm we port</item>
|
||||
/// <item><c>references/ACE/.../FileTypes/MotionTable.cs</c>
|
||||
/// — server-side parser, confirms key encoding</item>
|
||||
/// <item><c>references/DatReaderWriter/.../Generated/DBObjs/MotionTable.generated.cs</c>
|
||||
/// — exact field names and types</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MotionResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Walk an entity's motion-table chain and return the per-part
|
||||
/// animation frame for its default idle state. Returns <c>null</c>
|
||||
/// if any link in the chain is missing — caller should fall back
|
||||
/// to <c>Setup.PlacementFrames</c>.
|
||||
/// </summary>
|
||||
/// <param name="setup">The entity's base Setup dat.</param>
|
||||
/// <param name="dats">Dat collection used to load the linked MotionTable + Animation.</param>
|
||||
/// <param name="motionTableIdOverride">
|
||||
/// Optional override for the motion table id. Defaults to
|
||||
/// <c>Setup.DefaultMotionTable</c>. The server's CreateObject can
|
||||
/// supply a per-instance MotionTable id via PhysicsDescriptionFlag.MTable
|
||||
/// — pass that here when present so character outfits/states use the
|
||||
/// correct table.
|
||||
/// </param>
|
||||
public static AnimationFrame? GetIdleFrame(
|
||||
Setup setup,
|
||||
DatCollection dats,
|
||||
uint? motionTableIdOverride = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(setup);
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
|
||||
uint mtableId = motionTableIdOverride ?? (uint)setup.DefaultMotionTable;
|
||||
if (mtableId == 0) return null;
|
||||
|
||||
var mtable = dats.Get<MotionTable>(mtableId);
|
||||
if (mtable is null) return null;
|
||||
|
||||
// Step 1: find the substate that DefaultStyle maps to.
|
||||
if (!mtable.StyleDefaults.TryGetValue(mtable.DefaultStyle, out var defaultSubstate))
|
||||
return null;
|
||||
|
||||
// Step 2: compose the cycle key. ACViewer's encoding:
|
||||
// cycle = (DefaultStyle << 16) | (substate & 0xFFFFFF)
|
||||
// Cast through uint then int because Cycles is keyed by int.
|
||||
uint defaultStyleVal = (uint)mtable.DefaultStyle;
|
||||
uint substateVal = (uint)defaultSubstate;
|
||||
int cycleKey = (int)((defaultStyleVal << 16) | (substateVal & 0xFFFFFF));
|
||||
|
||||
if (!mtable.Cycles.TryGetValue(cycleKey, out var motionData) || motionData is null)
|
||||
return null;
|
||||
if (motionData.Anims.Count == 0) return null;
|
||||
|
||||
var animData = motionData.Anims[0];
|
||||
|
||||
// Step 3: load the Animation and grab the first frame.
|
||||
uint animId = (uint)animData.AnimId;
|
||||
if (animId == 0) return null;
|
||||
|
||||
var animation = dats.Get<Animation>(animId);
|
||||
if (animation is null) return null;
|
||||
if (animation.PartFrames.Count == 0) return null;
|
||||
|
||||
// animData.LowFrame is the start frame index of the cycle within the
|
||||
// animation. Clamp defensively in case it's out of range.
|
||||
int frameIdx = animData.LowFrame;
|
||||
if (frameIdx < 0 || frameIdx >= animation.PartFrames.Count)
|
||||
frameIdx = 0;
|
||||
|
||||
return animation.PartFrames[frameIdx];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue