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>
This commit is contained in:
parent
b7a9322b40
commit
3f41872d88
6 changed files with 341 additions and 11 deletions
|
|
@ -1467,6 +1467,60 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
// No-op if same; the sequencer's fast path guards against that.
|
// No-op if same; the sequencer's fast path guards against that.
|
||||||
ae.Sequencer.SetCycle(fullStyle, fullMotion, speedMod);
|
ae.Sequencer.SetCycle(fullStyle, fullMotion, speedMod);
|
||||||
|
|
||||||
|
// Route the Commands list — one-shot Actions, Modifiers, and
|
||||||
|
// ChatEmotes (mask classes 0x10, 0x20, 0x13 per r03 §3). These
|
||||||
|
// live in the motion table's Links / Modifiers dicts, not
|
||||||
|
// Cycles, and are played on top of the current cycle via
|
||||||
|
// PlayAction which resolves the right dict and interleaves the
|
||||||
|
// action frames before the cyclic tail.
|
||||||
|
//
|
||||||
|
// A typical NPC wave looks like:
|
||||||
|
// ForwardCommand=0 (Ready) + Commands=[{0x0087=Wave, speed=1.0}]
|
||||||
|
// [{0x0003=Ready, ...}]
|
||||||
|
// Each item runs through PlayAction (for 0x10/0x20 mask) or the
|
||||||
|
// standard SetCycle path (for 0x40 SubState). We leave SubState
|
||||||
|
// commands to fall through to the next UpdateMotion; that's how
|
||||||
|
// retail handles transition sequences (Wave → Ready).
|
||||||
|
if (update.MotionState.Commands is { Count: > 0 } cmds)
|
||||||
|
{
|
||||||
|
foreach (var item in cmds)
|
||||||
|
{
|
||||||
|
// Restore the 32-bit MotionCommand from the wire's 16-bit
|
||||||
|
// truncation by OR-ing class bits. The class is encoded
|
||||||
|
// in the low byte's high nibble via command ranges:
|
||||||
|
// 0x0003, 0x0005-0x0010 — SubState class (0x40xx xxxx)
|
||||||
|
// 0x0087 (Wave), 0x007D (BowDeep) etc — ChatEmote (0x13xx xxxx)
|
||||||
|
// 0x0051-0x00A1 — Action class (0x10xx xxxx)
|
||||||
|
//
|
||||||
|
// The retail MotionCommand enum carries the class byte in
|
||||||
|
// bits 24-31. DatReaderWriter's enum values match. For
|
||||||
|
// broadcasts, servers emit only low 16 bits (ACE
|
||||||
|
// InterpretedMotionState.cs:139). We reconstruct via a
|
||||||
|
// range-based lookup. See MotionCommand.generated.cs.
|
||||||
|
uint fullCmd = AcDream.Core.Physics.MotionCommandResolver.ReconstructFullCommand(item.Command);
|
||||||
|
if (fullCmd == 0) continue;
|
||||||
|
|
||||||
|
// Action class: play through the link dict then drop back
|
||||||
|
// to the current cycle. Modifier class: resolve from the
|
||||||
|
// Modifiers dict and combine on top. SubState: cycle
|
||||||
|
// change; route through SetCycle so the style-specific
|
||||||
|
// cycle fallback applies.
|
||||||
|
uint cls = fullCmd & 0xFF000000u;
|
||||||
|
if ((cls & 0x10000000u) != 0 || (cls & 0x20000000u) != 0
|
||||||
|
|| cls == 0x12000000u || cls == 0x13000000u)
|
||||||
|
{
|
||||||
|
ae.Sequencer.PlayAction(fullCmd, item.Speed);
|
||||||
|
}
|
||||||
|
else if ((cls & 0x40000000u) != 0)
|
||||||
|
{
|
||||||
|
// Substate in the command list — typically the "and
|
||||||
|
// then return to Ready" item. Update the cycle.
|
||||||
|
ae.Sequencer.SetCycle(fullStyle, fullCmd, item.Speed);
|
||||||
|
}
|
||||||
|
// else: Style / UI / Toggle class — not animation-driving.
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace AcDream.Core.Net.Messages;
|
namespace AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
|
@ -109,7 +110,27 @@ public static class CreateObject
|
||||||
/// Nullified Statue of a Drudge, which is rendered in the wrong pose
|
/// Nullified Statue of a Drudge, which is rendered in the wrong pose
|
||||||
/// if you only consult the MotionTable's default style.
|
/// if you only consult the MotionTable's default style.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct ServerMotionState(ushort Stance, ushort? ForwardCommand, float? ForwardSpeed = null);
|
public readonly record struct ServerMotionState(
|
||||||
|
ushort Stance,
|
||||||
|
ushort? ForwardCommand,
|
||||||
|
float? ForwardSpeed = null,
|
||||||
|
IReadOnlyList<MotionItem>? Commands = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
|
||||||
|
/// The server packs 0..many of these per broadcast: emotes, attacks,
|
||||||
|
/// and other one-shot motions arrive here, not in ForwardCommand.
|
||||||
|
///
|
||||||
|
/// Wire layout (see ACE Network/Motion/MotionItem.cs):
|
||||||
|
/// u16 command — low 16 bits of MotionCommand (Action class
|
||||||
|
/// typically 0x10xx; ChatEmote 0x13xx)
|
||||||
|
/// u16 packedSequence — bit 15 IsAutonomous, bits 0-14 sequence stamp
|
||||||
|
/// f32 speed — speedMod for the animation
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct MotionItem(
|
||||||
|
ushort Command,
|
||||||
|
ushort PackedSequence,
|
||||||
|
float Speed);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Server instruction to replace the surface texture at
|
/// Server instruction to replace the surface texture at
|
||||||
|
|
@ -480,6 +501,7 @@ public static class CreateObject
|
||||||
|
|
||||||
ushort? forwardCommand = null;
|
ushort? forwardCommand = null;
|
||||||
float? forwardSpeed = null;
|
float? forwardSpeed = null;
|
||||||
|
List<MotionItem>? commands = null;
|
||||||
|
|
||||||
// 0 = Invalid is the only union variant we care about for static
|
// 0 = Invalid is the only union variant we care about for static
|
||||||
// entities. Walking/turning entities use the other variants but
|
// entities. Walking/turning entities use the other variants but
|
||||||
|
|
@ -488,21 +510,20 @@ public static class CreateObject
|
||||||
if (movementType == 0)
|
if (movementType == 0)
|
||||||
{
|
{
|
||||||
// InterpretedMotionState: u32 (flags | numCommands<<7), then
|
// InterpretedMotionState: u32 (flags | numCommands<<7), then
|
||||||
// each present field in flag order. We only care about
|
// each present field in flag order. Flag bits (low 7) are
|
||||||
// ForwardCommand, so read in order and stop early if we
|
// CurrentStyle/ForwardCommand/.../TurnSpeed; numCommands is
|
||||||
// can't get that far.
|
// the MotionItem list length that follows after the speed
|
||||||
|
// fields (see ACE InterpretedMotionState.cs::Write).
|
||||||
if (mv.Length - p < 4) return new ServerMotionState(currentStyle, null);
|
if (mv.Length - p < 4) return new ServerMotionState(currentStyle, null);
|
||||||
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(mv.Slice(p));
|
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(mv.Slice(p));
|
||||||
p += 4;
|
p += 4;
|
||||||
uint flags = packed & 0x7Fu; // MovementStateFlag bits live in low 7 bits
|
uint flags = packed & 0x7Fu; // MovementStateFlag bits live in low 7 bits
|
||||||
|
uint numCommands = packed >> 7;
|
||||||
|
|
||||||
// CurrentStyle (0x1)
|
// CurrentStyle (0x1)
|
||||||
if ((flags & 0x1u) != 0)
|
if ((flags & 0x1u) != 0)
|
||||||
{
|
{
|
||||||
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
|
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
|
||||||
// The InterpretedMotionState's CurrentStyle is just a copy
|
|
||||||
// of MovementData.CurrentStyle per ACE source. Read and
|
|
||||||
// prefer it as the more specific value.
|
|
||||||
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
||||||
p += 2;
|
p += 2;
|
||||||
}
|
}
|
||||||
|
|
@ -525,10 +546,32 @@ public static class CreateObject
|
||||||
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p));
|
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p));
|
||||||
p += 4;
|
p += 4;
|
||||||
}
|
}
|
||||||
|
// SidestepSpeed (0x20) — skip
|
||||||
|
if ((flags & 0x20u) != 0) { if (mv.Length - p < 4) goto done; p += 4; }
|
||||||
|
// TurnSpeed (0x40) — skip
|
||||||
|
if ((flags & 0x40u) != 0) { if (mv.Length - p < 4) goto done; p += 4; }
|
||||||
|
|
||||||
|
// Commands list: numCommands × 8-byte MotionItem (u16 cmd +
|
||||||
|
// u16 packedSeq + f32 speed). One-shot actions, emotes,
|
||||||
|
// attacks — everything that's NOT a looping cycle change
|
||||||
|
// arrives here. Cap read at the buffer boundary.
|
||||||
|
if (numCommands > 0 && numCommands < 1024)
|
||||||
|
{
|
||||||
|
commands = new List<MotionItem>((int)numCommands);
|
||||||
|
for (int i = 0; i < numCommands; i++)
|
||||||
|
{
|
||||||
|
if (mv.Length - p < 8) break;
|
||||||
|
ushort cmd = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
|
||||||
|
ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p + 2));
|
||||||
|
float speed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p + 4));
|
||||||
|
p += 8;
|
||||||
|
commands.Add(new MotionItem(cmd, seq, speed));
|
||||||
|
}
|
||||||
|
}
|
||||||
done:;
|
done:;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed);
|
return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace AcDream.Core.Net.Messages;
|
namespace AcDream.Core.Net.Messages;
|
||||||
|
|
||||||
|
|
@ -122,17 +123,19 @@ public static class UpdateMotion
|
||||||
|
|
||||||
ushort? forwardCommand = null;
|
ushort? forwardCommand = null;
|
||||||
float? forwardSpeed = null;
|
float? forwardSpeed = null;
|
||||||
|
List<CreateObject.MotionItem>? commands = null;
|
||||||
|
|
||||||
if (movementType == 0)
|
if (movementType == 0)
|
||||||
{
|
{
|
||||||
// InterpretedMotionState — same layout as in CreateObject's
|
// InterpretedMotionState — same layout as in CreateObject's
|
||||||
// MovementInvalid branch, just reached via the header'd path.
|
// MovementInvalid branch, just reached via the header'd path.
|
||||||
// Only ForwardCommand is pulled out; the rest is deliberately
|
// Includes the Commands list (MotionItem[]) that carries
|
||||||
// ignored because the animation system consumes nothing else.
|
// Actions, emotes, and other one-shots not in ForwardCommand.
|
||||||
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
||||||
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||||
pos += 4;
|
pos += 4;
|
||||||
uint flags = packed & 0x7Fu;
|
uint flags = packed & 0x7Fu;
|
||||||
|
uint numCommands = packed >> 7;
|
||||||
|
|
||||||
// CurrentStyle (0x1) — prefer the InterpretedMotionState's copy
|
// CurrentStyle (0x1) — prefer the InterpretedMotionState's copy
|
||||||
// if present, matching the CreateObject parser's behavior.
|
// if present, matching the CreateObject parser's behavior.
|
||||||
|
|
@ -161,10 +164,30 @@ public static class UpdateMotion
|
||||||
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||||
pos += 4;
|
pos += 4;
|
||||||
}
|
}
|
||||||
|
// SidestepSpeed (0x20) — skip
|
||||||
|
if ((flags & 0x20u) != 0) { if (body.Length - pos < 4) goto done; pos += 4; }
|
||||||
|
// TurnSpeed (0x40) — skip
|
||||||
|
if ((flags & 0x40u) != 0) { if (body.Length - pos < 4) goto done; pos += 4; }
|
||||||
|
|
||||||
|
// Commands list: actions/emotes/attacks. Guard against a
|
||||||
|
// malformed numCommands by capping at a sane max.
|
||||||
|
if (numCommands > 0 && numCommands < 1024)
|
||||||
|
{
|
||||||
|
commands = new List<CreateObject.MotionItem>((int)numCommands);
|
||||||
|
for (int i = 0; i < numCommands; i++)
|
||||||
|
{
|
||||||
|
if (body.Length - pos < 8) break;
|
||||||
|
ushort cmd = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||||
|
ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 2));
|
||||||
|
float speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 4));
|
||||||
|
pos += 8;
|
||||||
|
commands.Add(new CreateObject.MotionItem(cmd, seq, speed));
|
||||||
|
}
|
||||||
|
}
|
||||||
done:;
|
done:;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand, forwardSpeed));
|
return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands));
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
|
||||||
89
src/AcDream.Core/Physics/MotionCommandResolver.cs
Normal file
89
src/AcDream.Core/Physics/MotionCommandResolver.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
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 < Modifier < SubState < 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -110,6 +110,74 @@ public class UpdateMotionTests
|
||||||
Assert.Null(result.Value.MotionState.ForwardCommand);
|
Assert.Null(result.Value.MotionState.ForwardCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsesForwardSpeed_WhenSpeedFlagSet()
|
||||||
|
{
|
||||||
|
// Flags = CurrentStyle | ForwardCommand | ForwardSpeed (0x1|0x2|0x10 = 0x13)
|
||||||
|
// Test value: 1.5× speed — matches a typical RunRate broadcast.
|
||||||
|
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 4];
|
||||||
|
int p = 0;
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1A2B3C4Du); p += 4;
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||||||
|
p += 6; // MovementData header
|
||||||
|
body[p++] = 0;
|
||||||
|
body[p++] = 0;
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x13u); p += 4;
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // NonCombat
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // RunForward
|
||||||
|
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4; // speed
|
||||||
|
|
||||||
|
var result = UpdateMotion.TryParse(body);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance);
|
||||||
|
Assert.Equal((ushort)0x0007, result.Value.MotionState.ForwardCommand);
|
||||||
|
Assert.Equal(1.5f, result.Value.MotionState.ForwardSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsesCommandsList_Wave()
|
||||||
|
{
|
||||||
|
// A typical NPC wave broadcast:
|
||||||
|
// - stance NonCombat (0x003D)
|
||||||
|
// - ForwardCommand flag set, command = 0x0003 (Ready)
|
||||||
|
// - numCommands = 1, with a single MotionItem{ cmd=0x0087 Wave, seq=0, speed=1.0 }
|
||||||
|
//
|
||||||
|
// Packed u32 = (flags | numCommands << 7)
|
||||||
|
// flags = 0x01 (CurrentStyle) | 0x02 (ForwardCommand) = 0x03
|
||||||
|
// numCommands << 7 = 1 << 7 = 0x80
|
||||||
|
// total = 0x83
|
||||||
|
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 8];
|
||||||
|
int p = 0;
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xDEADBEEFu); p += 4;
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||||||
|
p += 6;
|
||||||
|
body[p++] = 0;
|
||||||
|
body[p++] = 0;
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x83u); p += 4; // flags=0x3 + numCommands=1
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // stance
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0003); p += 2; // fwd cmd = Ready
|
||||||
|
|
||||||
|
// MotionItem: u16 command + u16 packedSeq + f32 speed
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0087); p += 2; // Wave
|
||||||
|
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0001); p += 2;
|
||||||
|
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
|
||||||
|
|
||||||
|
var result = UpdateMotion.TryParse(body);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance);
|
||||||
|
Assert.Equal((ushort)0x0003, result.Value.MotionState.ForwardCommand);
|
||||||
|
|
||||||
|
Assert.NotNull(result.Value.MotionState.Commands);
|
||||||
|
Assert.Single(result.Value.MotionState.Commands!);
|
||||||
|
var wave = result.Value.MotionState.Commands![0];
|
||||||
|
Assert.Equal((ushort)0x0087, wave.Command);
|
||||||
|
Assert.Equal(1.0f, wave.Speed);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance()
|
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates MotionCommandResolver — reconstructs the class byte (0x10, 0x13,
|
||||||
|
/// 0x41, 0x80, etc) from a 16-bit wire value. Without this, the sequencer
|
||||||
|
/// routes commands to the wrong MotionTable dict and NPC emotes/attacks
|
||||||
|
/// silently fail.
|
||||||
|
/// </summary>
|
||||||
|
public class MotionCommandResolverTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
// SubState / Ready / Movement commands
|
||||||
|
[InlineData(0x0003, 0x41000003u)] // Ready
|
||||||
|
[InlineData(0x0005, 0x45000005u)] // WalkForward
|
||||||
|
[InlineData(0x0007, 0x44000007u)] // RunForward
|
||||||
|
[InlineData(0x0006, 0x45000006u)] // WalkBackward
|
||||||
|
[InlineData(0x000D, 0x6500000Du)] // TurnRight
|
||||||
|
[InlineData(0x000E, 0x6500000Eu)] // TurnLeft
|
||||||
|
[InlineData(0x000F, 0x6500000Fu)] // SideStepRight
|
||||||
|
[InlineData(0x0015, 0x40000015u)] // Falling
|
||||||
|
// Action-class one-shots: melee attacks, death, portals
|
||||||
|
[InlineData(0x0057, 0x10000057u)] // Sanctuary (death)
|
||||||
|
[InlineData(0x0058, 0x10000058u)] // ThrustMed
|
||||||
|
[InlineData(0x005B, 0x1000005Bu)] // SlashHigh
|
||||||
|
[InlineData(0x0061, 0x10000061u)] // Shoot
|
||||||
|
[InlineData(0x004B, 0x1000004Bu)] // Jumpup
|
||||||
|
[InlineData(0x0050, 0x10000050u)] // FallDown
|
||||||
|
// ChatEmotes (class 0x13)
|
||||||
|
[InlineData(0x0087, 0x13000087u)] // Wave
|
||||||
|
[InlineData(0x0080, 0x13000080u)] // Laugh
|
||||||
|
[InlineData(0x007D, 0x1300007Du)] // BowDeep
|
||||||
|
public void ReconstructsKnownCommands(ushort wire, uint expected)
|
||||||
|
{
|
||||||
|
uint got = MotionCommandResolver.ReconstructFullCommand(wire);
|
||||||
|
Assert.Equal(expected, got);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZeroWireReturnsZero()
|
||||||
|
{
|
||||||
|
Assert.Equal(0u, MotionCommandResolver.ReconstructFullCommand(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnknownWireReturnsZero()
|
||||||
|
{
|
||||||
|
// 0xFFFF is not a real MotionCommand low-16.
|
||||||
|
Assert.Equal(0u, MotionCommandResolver.ReconstructFullCommand(0xFFFF));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue