acdream/src/AcDream.Core/Meshing/MotionResolver.cs
Erik 5666e05a85 fix(core+app): Phase 6.8 — keep NPCs animated when post-spawn motion update is unmappable
User reported that some NPCs (Pathwarden, Town Crier) didn't breathe.
Diagnostic logging revealed:

1. Both NPCs are correctly registered as animated at CreateObject
   time with the standard 30fps human breath cycle (anim=0x03000001).
2. Immediately after spawn the server sends an UpdateMotion with
   stance=0x0003 cmd=0x0000 for these NPCs.
3. MotionResolver.GetIdleCycle returned NULL for that combination,
   because StyleDefaults didn't have an entry for stance=3.
4. OnLiveMotionUpdated treated the NULL as "switch to a static pose"
   and removed the entity from _animatedEntities.

Two fixes:

A. MotionResolver.ResolveIdleCycleInternal — when stance is set but
   StyleDefaults has no entry for it, fall back to the table's
   DefaultStyle/DefaultSubstate instead of returning null. The
   server-supplied stance was just an unmappable override; the table
   default is the correct "I have no better information" answer.
   Pulled the table-default lookup into a small TryGetTableDefault
   helper so both fallback paths use the same code.

B. OnLiveMotionUpdated — never REMOVE an animated entity. If the
   re-resolved cycle is bad (null, framerate=0, or single-frame),
   leave the existing cycle running so the entity continues to
   breathe with whatever it already had. The defensive "remove on
   re-resolve failure" was the bug — it silently un-registered NPCs
   the moment the server sent any partial motion update.

Together these mean: any NPC that successfully registers as animated
at spawn stays animated, even if the server's subsequent motion
updates are incomplete or use stance values our resolver doesn't
have a mapping for.

Strips the [BREATHE] and [MOTION] diagnostic spew added during the
investigation now that the cause is identified.

220 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:05:23 +02:00

247 lines
10 KiB
C#

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>
/// <summary>
/// Per-entity idle-cycle playback descriptor returned by
/// <see cref="GetIdleCycle"/>. Holds the resolved Animation plus the
/// frame range and framerate from the MotionTable's AnimData entry.
/// Phase 6.4 uses this to drive per-frame playback (so creatures
/// breathe instead of being frozen on a single frame).
/// </summary>
public sealed record IdleCycle(
Animation Animation,
int LowFrame,
int HighFrame,
float Framerate);
/// <summary>
/// Same resolution algorithm as <see cref="GetIdleFrame"/> but
/// returns the full cycle metadata so the caller can advance frames
/// over time. Returns null if any link in the chain is missing.
/// </summary>
public static IdleCycle? GetIdleCycle(
Setup setup,
DatCollection dats,
uint? motionTableIdOverride = null,
ushort? stanceOverride = null,
ushort? commandOverride = null)
{
var resolved = ResolveIdleCycleInternal(setup, dats, motionTableIdOverride, stanceOverride, commandOverride);
if (resolved is null) return null;
var (anim, ad) = resolved.Value;
// Sentinel resolution — matches ACViewer's AnimSequenceNode.set_animation_id
// (references/ACViewer/.../Physics/Animation/AnimSequenceNode.cs:96-113).
//
// AnimData in the dats encodes a few sentinel values that the raw int
// fields can't represent directly:
// * HighFrame == -1 → "play the whole animation" (use NumFrames - 1)
// * HighFrame > NumFrames - 1 → clamp (defensive for malformed data)
// * LowFrame >= NumFrames → clamp to NumFrames - 1
// * LowFrame > HighFrame after clamping → collapse to a single frame
//
// Our Phase 6.1 code naively used the raw LowFrame/HighFrame and then
// rejected the cycle if `HighFrame <= LowFrame`, which silently threw
// away every animated creature whose AnimData used the -1 sentinel —
// i.e. almost all of them. Resolving here makes the IdleCycle carry
// the real frame range, so downstream playback/filtering sees a span
// it can advance through.
int numFrames = anim.PartFrames.Count;
int lowFrame = ad.LowFrame;
int highFrame = ad.HighFrame;
if (highFrame < 0)
highFrame = numFrames - 1;
if (lowFrame >= numFrames)
lowFrame = numFrames - 1;
if (highFrame >= numFrames)
highFrame = numFrames - 1;
if (lowFrame < 0)
lowFrame = 0;
if (lowFrame > highFrame)
highFrame = lowFrame;
return new IdleCycle(anim, lowFrame, highFrame, ad.Framerate);
}
public static AnimationFrame? GetIdleFrame(
Setup setup,
DatCollection dats,
uint? motionTableIdOverride = null,
ushort? stanceOverride = null,
ushort? commandOverride = null)
{
var resolved = ResolveIdleCycleInternal(setup, dats, motionTableIdOverride, stanceOverride, commandOverride);
if (resolved is null) return null;
var (animation, animData) = resolved.Value;
int frameIdx = animData.LowFrame;
if (frameIdx < 0 || frameIdx >= animation.PartFrames.Count)
frameIdx = 0;
return animation.PartFrames[frameIdx];
}
/// <summary>
/// Shared cycle-resolution path for both <see cref="GetIdleFrame"/>
/// and <see cref="GetIdleCycle"/>. Returns the loaded Animation +
/// the AnimData that describes its frame range and framerate, or
/// null if any link in the chain is missing.
/// </summary>
private static (Animation, AnimData)? ResolveIdleCycleInternal(
Setup setup,
DatCollection dats,
uint? motionTableIdOverride,
ushort? stanceOverride,
ushort? commandOverride)
{
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;
// Resolve (style, substate) with priority:
// 1. Server-sent stance + command (CreateObject MovementData) — needed
// for entities like the Foundry's drudge statue, which override the
// MotionTable default with an aggressive crouch.
// 2. Server-sent stance only — substate falls back to that style's
// StyleDefaults entry.
// 3. MotionTable.DefaultStyle + StyleDefaults — the upright/Ready
// idle for everything else.
uint styleVal;
uint substateVal;
// Helper: pick the table's default (style, substate) — used as the
// ultimate fallback when caller-supplied overrides don't resolve.
bool TryGetTableDefault(out uint styleOut, out uint substateOut)
{
if (mtable.StyleDefaults.TryGetValue(mtable.DefaultStyle, out var defaultSubstate))
{
styleOut = (uint)mtable.DefaultStyle;
substateOut = (uint)defaultSubstate;
return true;
}
styleOut = 0;
substateOut = 0;
return false;
}
if (stanceOverride is { } stance && stance != 0)
{
styleVal = stance;
if (commandOverride is { } cmd && cmd != 0)
{
substateVal = cmd;
}
else if (mtable.StyleDefaults.TryGetValue((DatReaderWriter.Enums.MotionCommand)styleVal, out var subFromStyle))
{
substateVal = (uint)subFromStyle;
}
else
{
// The server gave us a stance the motion table doesn't recognize
// (e.g. NPCs that re-broadcast a "ready" stance with a substate
// value where the table only has a style entry). Don't return
// null — fall back to the table default so the entity at least
// animates with its idle cycle. Returning null here was the
// bug that silently un-registered Pathwarden / Town Crier from
// _animatedEntities the moment ACE sent a post-spawn motion
// update with stance=0x0003 cmd=0x0000.
if (!TryGetTableDefault(out styleVal, out substateVal))
return null;
}
}
else
{
if (!TryGetTableDefault(out styleVal, out substateVal))
return null;
}
// ACViewer's cycle key encoding (Physics/Animation/MotionTable.cs:191):
// cycle = (style << 16) | (substate & 0xFFFFFF)
int cycleKey = (int)((styleVal << 16) | (substateVal & 0xFFFFFF));
// Try the server-supplied combo first; if it doesn't resolve, fall back
// to the table's default style + that style's default substate. This
// matters when the server sends a (stance, command) pair the table
// doesn't have a cycle entry for — better an upright pose than nothing.
if (!mtable.Cycles.TryGetValue(cycleKey, out var motionData) || motionData is null
|| motionData.Anims.Count == 0)
{
if (mtable.StyleDefaults.TryGetValue(mtable.DefaultStyle, out var fallbackSub))
{
int fallbackKey = (int)(((uint)mtable.DefaultStyle << 16) | ((uint)fallbackSub & 0xFFFFFF));
if (!mtable.Cycles.TryGetValue(fallbackKey, out motionData) || motionData is null)
return null;
if (motionData.Anims.Count == 0) return null;
}
else
{
return null;
}
}
var animData = motionData.Anims[0];
// Load the Animation referenced by the cycle.
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;
return (animation, animData);
}
}