acdream/src/AcDream.Core/Physics/MotionCommandResolver.cs
Erik 3f41872d88 feat(anim): route Commands[] list — full NPC/monster motion support
UpdateMotion's InterpretedMotionState payload includes not just
ForwardCommand but a whole Commands[] list of MotionItem entries — each
carrying an Action (attack, portal, skill use), Modifier (jump,
stop-turn), or ChatEmote (Wave, BowDeep, Laugh) that should overlay the
current cycle. The old parser stopped reading after ForwardSpeed, so
emotes/attacks/deaths never reached the sequencer and NPCs just sat in
their idle cycle.

Three parts:

1. New MotionItem wire record in ServerMotionState — carries Command
   (u16), PackedSequence (u16 with IsAutonomous bit + 15-bit stamp),
   and Speed (f32). Mirrors ACE Network/Motion/MotionItem.cs.

2. Both UpdateMotion.TryParse and CreateObject.TryParseMovementData
   now read the full InterpretedMotionState: all 7 flag fields
   (CurrentStyle, ForwardCommand, SidestepCommand, TurnCommand,
   ForwardSpeed, SidestepSpeed, TurnSpeed) plus the numCommands ×
   MotionItem tail. The packed u32 encodes flags in low 7 bits and
   command count in bits 7+ (see ACE InterpretedMotionState.cs:131).

3. New MotionCommandResolver — reconstructs the 32-bit MotionCommand
   class byte from a 16-bit wire value via a reflection-built lookup
   of DatReaderWriter.Enums.MotionCommand. Server serializes as u16
   (ACE InterpretedMotionState.cs:139) and we need the class to route:
     - 0x10xxxxxx Action / 0x20xxxxxx Modifier / 0x12,0x13 ChatEmote →
       PlayAction (resolves from Modifiers or Links dict, overlays on
       current cycle)
     - 0x40xxxxxx SubState → SetCycle (cycle change)

4. OnLiveMotionUpdated in GameWindow dispatches each command:
     - SubState class (0x40xxx) → SetCycle (treated same as
       ForwardCommand)
     - Action/Modifier/ChatEmote → PlayAction — the link animation
       plays once then drops back to the current cycle naturally
       (matches retail's action-queue pattern in CMotionInterp
       DoInterpretedMotion, decompile FUN_00528F70).

Result: NPCs now animate attacks, waves, bows, death throes, and other
one-shots that ACE broadcasts via the Commands list rather than the
primary ForwardCommand field. Combined with the dead-reckoning + speed-
scaling from the prior commits, remote characters look visually correct
during the full motion spectrum (idle → walk → run → attack → death).

Tests: 2 new UpdateMotion wire-format tests (ForwardSpeed parse, full
Wave command list parse) + 19 new MotionCommandResolver reconstruction
tests covering SubState, Action, and ChatEmote classes. 654 tests green
(was 633).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:34:18 +02:00

89 lines
3.5 KiB
C#

using System;
using System.Collections.Generic;
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
namespace AcDream.Core.Physics;
/// <summary>
/// Reconstructs the 32-bit retail <see cref="DRWMotionCommand"/> value from
/// a 16-bit wire value broadcast in <c>InterpretedMotionState.Commands[]</c>.
///
/// <para>
/// The server serializes MotionCommands as <c>u16</c> (ACE
/// <c>InterpretedMotionState.cs:139</c>), truncating the class byte (Style /
/// SubState / Modifier / Action / ChatEmote / UI / Toggle / Mappable /
/// Command — see r03 §3.1). The client must re-attach the class byte before
/// routing the command into the motion table, because the same low 16 bits
/// can map to different classes (e.g. 0x0003 is <c>Ready</c> as a SubState,
/// but there's no other 0x0003).
/// </para>
///
/// <para>
/// This is implemented as an eager lookup table built from all values of
/// <see cref="DRWMotionCommand"/> via reflection. If the wire value matches
/// more than one enum value (different class bits), we prefer the
/// lowest-class-numbered variant that has a non-zero class byte — roughly
/// matching retail priority (Action &lt; Modifier &lt; SubState &lt; Style).
/// </para>
///
/// <para>
/// Cited references:
/// <list type="bullet">
/// <item><description>
/// <c>references/ACE/Source/ACE.Server/Network/Motion/InterpretedMotionState.cs::Write</c>
/// L138-L144 — writer emits u16 for every command field.
/// </description></item>
/// <item><description>
/// <c>references/ACE/Source/ACE.Entity/Enum/CommandMasks.cs</c> — the
/// class bit assignments: 0x80=Style, 0x40=SubState, 0x20=Modifier,
/// 0x10=Action, 0x13 and 0x12=ChatEmote (with Mappable set), etc.
/// </description></item>
/// <item><description>
/// <c>docs/research/deepdives/r03-motion-animation.md</c> §3 — complete
/// command catalogue.
/// </description></item>
/// </list>
/// </para>
/// </summary>
public static class MotionCommandResolver
{
// Lookup table built eagerly at type-init. Sparse: only values that
// appear in the DRW enum (which came from the generated protocol XML)
// are present. ~450 entries typical.
private static readonly Dictionary<ushort, uint> s_lookup = BuildLookup();
/// <summary>
/// Given a 16-bit wire value, return the full 32-bit MotionCommand
/// (class byte restored). Returns 0 if no matching enum value exists.
/// </summary>
public static uint ReconstructFullCommand(ushort wireCommand)
{
if (wireCommand == 0) return 0u;
s_lookup.TryGetValue(wireCommand, out var full);
return full;
}
private static Dictionary<ushort, uint> BuildLookup()
{
var result = new Dictionary<ushort, uint>(512);
var values = Enum.GetValues(typeof(DRWMotionCommand));
foreach (DRWMotionCommand v in values)
{
uint full = (uint)v;
ushort lo = (ushort)(full & 0xFFFFu);
if (lo == 0) continue; // Invalid / unmappable
// If a value with this low-16-bit already exists, keep the one
// with the lower class byte (Action=0x10 beats SubState=0x41
// beats Style=0x80). This matches retail: the server tends to
// emit Actions and ChatEmotes far more often than Styles, so
// the Action-class reconstruction is the common case.
if (!result.TryGetValue(lo, out var existing)
|| (full >> 24) < (existing >> 24))
{
result[lo] = full;
}
}
return result;
}
}