feat(net+core): Phase 6.2 — honor server CurrentMotionState for idle pose

CreateObject's MovementData was being skipped past, so the renderer
always fell back to the MotionTable's default style/substate. That's
correct for most NPCs and characters but wrong for entities the
server explicitly puts into a non-default stance — most visibly the
Foundry's Nullified Statue of a Drudge, which the server sends with
a combat stance + Crouch ForwardCommand override and which therefore
rendered as an upright drudge instead of the aggressive crouched
statue you see on the retail client.

CreateObject.TryParse now extracts ServerMotionState (Stance +
optional ForwardCommand) from the inner MovementData. The header=false
layout was confirmed via ACE/.../WorldObject_Networking.cs:326 plus
MovementData.cs::Write and InterpretedMotionState.cs::Write. Only the
two fields the resolver needs are read; remaining InterpretedMotionState
bytes are skipped via the outer length so we don't have to handle
alignment of fields we don't care about.

MotionResolver.GetIdleFrame now takes optional stanceOverride and
commandOverride. Resolution priority is server-stance+command →
server-stance + style-default substate → MotionTable default. If the
composed cycle key doesn't resolve we fall back to the table default
rather than returning null, so a partial server override never makes
the entity worse than Phase 6.1.

160 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 18:48:33 +02:00
parent 090d265a4e
commit 120e801ecf
4 changed files with 181 additions and 23 deletions

View file

@ -53,7 +53,9 @@ public static class MotionResolver
public static AnimationFrame? GetIdleFrame(
Setup setup,
DatCollection dats,
uint? motionTableIdOverride = null)
uint? motionTableIdOverride = null,
ushort? stanceOverride = null,
ushort? commandOverride = null)
{
ArgumentNullException.ThrowIfNull(setup);
ArgumentNullException.ThrowIfNull(dats);
@ -64,20 +66,64 @@ public static class MotionResolver
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;
// 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;
// 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 (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
{
return null;
}
}
else
{
if (!mtable.StyleDefaults.TryGetValue(mtable.DefaultStyle, out var defaultSubstate))
return null;
styleVal = (uint)mtable.DefaultStyle;
substateVal = (uint)defaultSubstate;
}
if (!mtable.Cycles.TryGetValue(cycleKey, out var motionData) || motionData is null)
return null;
if (motionData.Anims.Count == 0) 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];