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>
This commit is contained in:
Erik 2026-04-12 00:05:23 +02:00
parent 8c14e0207c
commit 5666e05a85
2 changed files with 42 additions and 15 deletions

View file

@ -162,6 +162,21 @@ public static class MotionResolver
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;
@ -175,15 +190,22 @@ public static class MotionResolver
}
else
{
return null;
// 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 (!mtable.StyleDefaults.TryGetValue(mtable.DefaultStyle, out var defaultSubstate))
if (!TryGetTableDefault(out styleVal, out substateVal))
return null;
styleVal = (uint)mtable.DefaultStyle;
substateVal = (uint)defaultSubstate;
}
// ACViewer's cycle key encoding (Physics/Animation/MotionTable.cs:191):