feat(net): Phase 6.6 — parse UpdateMotion (0xF74C) into MotionUpdated event
Server sends UpdateMotion whenever an entity's motion state changes: NPCs starting a walk cycle, creatures switching to a combat stance, doors opening, a player waving, etc. Phase 6.1-6.4 already handles rendering different (stance, forward-command) pairs for the INITIAL CreateObject, but without this message NPCs freeze in whatever pose they spawned with and never transition to walking/fighting. Added UpdateMotion.TryParse with the same ServerMotionState the CreateObject path uses, reached via a slightly different outer layout (guid + instance seq + header'd MovementData; the MovementData starts with the 8-byte sequence/autonomous header this time rather than being preceded by a length field). Only the (stance, forward- command) pair is extracted — same subset CreateObject grabs. WorldSession dispatches MotionUpdated(guid, state) when a 0xF74C body parses successfully. The App-side wiring (guid→entity lookup and AnimatedEntity cycle swap) is intentionally deferred to a separate commit because it touches GameWindow which is currently being edited by the Phase 9.1 translucent-pass work. 89 Core.Net tests (was 83, +6 for UpdateMotion coverage). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4752b8a528
commit
a71db90310
12 changed files with 675 additions and 45 deletions
134
tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
Normal file
134
tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Net.Tests.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="UpdateMotion.TryParse"/> — the 0xF74C GameMessage the
|
||||
/// server sends when an entity's motion state changes (NPC starts walking,
|
||||
/// creature enters combat, door opens, etc). The parser shares the inner
|
||||
/// MovementData decoder with CreateObject but reaches it through a
|
||||
/// different outer layout, so we need standalone coverage.
|
||||
/// </summary>
|
||||
public class UpdateMotionTests
|
||||
{
|
||||
[Fact]
|
||||
public void RejectsWrongOpcode()
|
||||
{
|
||||
var body = new byte[32];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
|
||||
Assert.Null(UpdateMotion.TryParse(body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsTruncated()
|
||||
{
|
||||
Assert.Null(UpdateMotion.TryParse(new byte[3]));
|
||||
Assert.Null(UpdateMotion.TryParse(Array.Empty<byte>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesStanceOnly_WhenForwardCommandFlagUnset()
|
||||
{
|
||||
// Layout:
|
||||
// u32 opcode = 0xF74C
|
||||
// u32 guid
|
||||
// u16 instanceSeq
|
||||
// u16 movementSeq + u16 serverControlSeq + u8 isAutonomous + 3 pad (= 8 bytes total header)
|
||||
// u8 movementType = 0 (Invalid)
|
||||
// u8 motionFlags = 0
|
||||
// u16 currentStyle (outer MovementData field) = 0x0042
|
||||
// u32 packed = CurrentStyle flag (0x1) only
|
||||
// u16 inner currentStyle = 0x0005 (overrides outer per InterpretedMotionState semantics)
|
||||
var body = new byte[4 + 4 + 2 + 8 + 4 + 4 + 2];
|
||||
int p = 0;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x12345678u); p += 4;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0001); p += 2;
|
||||
// 8-byte header slot — leave zero
|
||||
p += 8;
|
||||
body[p++] = 0; // movementType = Invalid
|
||||
body[p++] = 0; // motionFlags
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0042); p += 2;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1u); p += 4; // flags = CurrentStyle only
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0005); p += 2;
|
||||
|
||||
var result = UpdateMotion.TryParse(body);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0x12345678u, result!.Value.Guid);
|
||||
Assert.Equal((ushort)0x0005, result.Value.MotionState.Stance);
|
||||
Assert.Null(result.Value.MotionState.ForwardCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesStanceAndForwardCommand()
|
||||
{
|
||||
// Flags = CurrentStyle (0x1) | ForwardCommand (0x2)
|
||||
var body = new byte[4 + 4 + 2 + 8 + 4 + 4 + 2 + 2];
|
||||
int p = 0;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xABCDEF01u); p += 4;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0010); p += 2;
|
||||
p += 8; // MovementData header slot
|
||||
body[p++] = 0;
|
||||
body[p++] = 0;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0000); p += 2; // outer style = 0
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x3u); p += 4; // CurrentStyle + ForwardCommand
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x000D); p += 2; // stance = 0xD
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // forward command = 0x7 (Run)
|
||||
|
||||
var result = UpdateMotion.TryParse(body);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0xABCDEF01u, result!.Value.Guid);
|
||||
Assert.Equal((ushort)0x000D, result.Value.MotionState.Stance);
|
||||
Assert.Equal((ushort)0x0007, result.Value.MotionState.ForwardCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesNoFlagsSet_KeepsOuterStance()
|
||||
{
|
||||
// When the InterpretedMotionState flags are zero, neither the inner
|
||||
// currentStyle nor the forward command are present in the payload,
|
||||
// so the parser should fall back to the MovementData outer stance
|
||||
// field and leave ForwardCommand null.
|
||||
var body = new byte[4 + 4 + 2 + 8 + 4 + 4];
|
||||
int p = 0;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x55555555u); p += 4;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||||
p += 8;
|
||||
body[p++] = 0;
|
||||
body[p++] = 0;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00AA); p += 2;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no flags
|
||||
|
||||
var result = UpdateMotion.TryParse(body);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal((ushort)0x00AA, result!.Value.MotionState.Stance);
|
||||
Assert.Null(result.Value.MotionState.ForwardCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance()
|
||||
{
|
||||
// movementType != 0 means one of the Move* variants we don't parse.
|
||||
// The parser must still return a valid Parsed with the outer stance
|
||||
// and a null ForwardCommand rather than failing the whole message.
|
||||
var body = new byte[4 + 4 + 2 + 8 + 4];
|
||||
int p = 0;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x99999999u); p += 4;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
|
||||
p += 8;
|
||||
body[p++] = 1; // movementType = MoveToObject (non-Invalid)
|
||||
body[p++] = 0;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x00CC); p += 2;
|
||||
|
||||
var result = UpdateMotion.TryParse(body);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal((ushort)0x00CC, result!.Value.MotionState.Stance);
|
||||
Assert.Null(result.Value.MotionState.ForwardCommand);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue