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>
247 lines
10 KiB
C#
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);
|
|
}
|
|
}
|