acdream/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs
Erik cb74e64343 feat(L.2g-S1): retail movement-event staleness gate (DEV-6)
Port retail's three-stamp inbound gate for 0xF74C UpdateMotion:

- MotionSequenceGate (Core/Physics): CPhysicsObj::is_newer (0x00451ad0)
  wraparound u16 compare, verbatim per ACE PhysicsObj.is_newer (the BN
  pseudo-C setcc returns are garbled; ACE + branch structure are the
  oracle). Gates: INSTANCE_TS at dispatch (stale incarnation drops
  before any stamp is touched), MOVEMENT_TS strictly-newer (stamped
  BEFORE the server-control check, per CPhysics::SetObjectMovement
  0x00509690), SERVER_CONTROLLED_MOVE_TS drop-when-stored-newer.
- Seed from CreateObject's PhysicsDesc timestamp block (index 1 =
  ObjectMovement now parsed; ACE WorldObject_Networking.cs:411-420
  order) — without seeding, entities whose movement sequence is past
  0x8000 at spawn would drop every UM against a zero stamp.
  Adopt-on-first / advance-only-after, so the #138 rehydrate replay of
  retained spawns cannot regress live stamps.
- UpdateMotion + EntitySpawn now carry instance/movement/serverControl
  sequences + isAutonomous (was parsed-past; isAutonomous feeds the
  S2 funnel's last_move_was_autonomous). Gate wired at the top of
  OnLiveMotionUpdated before any state mutation; [UM_STALE] diag under
  ACDREAM_DUMP_MOTION / ACDREAM_REMOTE_VEL_DIAG; gate dropped with the
  entity on DeleteObject.

Register: AD-32 added (adopt-newer-incarnation instead of retail's
QueueBlobForObject); TS-26 updated (UM side closed, UP side open).
Deviation map: docs/research/2026-07-02-inbound-motion-deviation-map.md.

19 new gate tests + parser coverage; full suite 3276 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 15:44:06 +02:00

385 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 + 1 pad (= 6 bytes total header, per ACE Align())
// 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 + 6 + 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 += 6;
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 + 6 + 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 += 6; // 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 + 6 + 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 += 6;
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 ParsesForwardSpeed_WhenSpeedFlagSet()
{
// Flags = CurrentStyle | ForwardCommand | ForwardSpeed
// = 0x1 | 0x2 | 0x4 = 0x7
// (Per ACE MovementStateFlag enum — ForwardSpeed is bit 0x4,
// NOT 0x10. The earlier test had the wrong mapping; see
// references/ACE/Source/ACE.Entity/Enum/MovementStateFlag.cs)
// 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), 0x7u); 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]
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance()
{
// movementType != 0 means one of the Move* variants; a truncated
// non-Invalid payload still returns the outer state.
// 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 + 6 + 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 += 6;
body[p++] = 7; // movementType = MoveToPosition (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);
Assert.Equal((byte)7, result.Value.MotionState.MovementType);
Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
}
[Fact]
public void ParsesMoveToPositionSpeedAndRunRate()
{
// Layout after MovementData's movementType/motionFlags/currentStyle:
// Origin: cell + xyz (16 bytes)
// MoveToParameters: flags, distance, min, fail, speed,
// walk/run threshold, desired heading (28 bytes)
// runRate: f32
var body = new byte[4 + 4 + 2 + 6 + 4 + 16 + 28 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 7; // MoveToPosition
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 10f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 20f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 30f); p += 4;
const uint canWalkCanRunMoveTowards = 0x1u | 0x2u | 0x200u;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), canWalkCanRunMoveTowards); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 90.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4;
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((byte)7, result!.Value.MotionState.MovementType);
Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
Assert.Equal((ushort)0x003D, result.Value.MotionState.Stance);
Assert.Null(result.Value.MotionState.ForwardCommand);
Assert.Equal(canWalkCanRunMoveTowards, result.Value.MotionState.MoveToParameters);
Assert.Equal(1.25f, result.Value.MotionState.MoveToSpeed);
Assert.Equal(1.5f, result.Value.MotionState.MoveToRunRate);
Assert.True(result.Value.MotionState.MoveToCanRun);
Assert.True(result.Value.MotionState.MoveTowards);
// Phase L.1c (2026-04-28): full path payload retained.
Assert.NotNull(result.Value.MotionState.MoveToPath);
var path = result.Value.MotionState.MoveToPath!.Value;
Assert.Null(path.TargetGuid);
Assert.Equal(0xA8B4000Eu, path.OriginCellId);
Assert.Equal(10f, path.OriginX);
Assert.Equal(20f, path.OriginY);
Assert.Equal(30f, path.OriginZ);
Assert.Equal(0.6f, path.DistanceToObject);
Assert.Equal(0.0f, path.MinDistance);
Assert.Equal(float.MaxValue, path.FailDistance);
Assert.Equal(15.0f, path.WalkRunThreshold);
Assert.Equal(90.0f, path.DesiredHeading);
}
[Fact]
public void ParsesAttackHigh1_AsActionForwardCommand()
{
// Phase L.1c followup (2026-04-28): regression that verifies the
// wire-format ACE uses for melee swings — mt=0 with
// ForwardCommand=AttackHigh1 (0x0062 in low 16 bits) and
// ForwardSpeed (typically the animSpeed). The receiver in
// GameWindow.OnLiveMotionUpdated relies on this layout to bulk-copy
// ForwardCommand into the body's InterpretedState so that
// get_state_velocity returns 0 (gate is RunForward||WalkForward).
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x800003B5u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6; // header padding
body[p++] = 0; // mt = Invalid (interpreted)
body[p++] = 0; // motion_flags
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003C); p += 2; // stance: HandCombat
// InterpretedMotionState: flags = ForwardCommand (0x02) | ForwardSpeed (0x04)
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x06u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0062); p += 2; // AttackHigh1 low bits
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // animSpeed
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((byte)0, result!.Value.MotionState.MovementType);
Assert.False(result.Value.MotionState.IsServerControlledMoveTo);
Assert.Equal((ushort)0x0062, result.Value.MotionState.ForwardCommand);
Assert.Equal(1.25f, result.Value.MotionState.ForwardSpeed);
}
[Fact]
public void ParsesSequenceNumbersAndAutonomyFlag()
{
// L.2g S1 (DEV-6): the three staleness stamps + autonomy flag must
// survive parsing — retail gates every 0xF74C on them
// (INSTANCE_TS at dispatch, MOVEMENT_TS + SERVER_CONTROLLED_MOVE_TS
// in CPhysics::SetObjectMovement 0x00509690, which also stores
// last_move_was_autonomous).
var body = new byte[4 + 4 + 2 + 6 + 4 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x50001234u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0102); p += 2; // instanceSeq
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0304); p += 2; // movementSeq
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0506); p += 2; // serverControlSeq
body[p++] = 1; // isAutonomous
p += 1; // Align(4) pad
body[p++] = 0; // movementType = Invalid
body[p++] = 0; // motionFlags
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // outer stance
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no IMS flags
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((ushort)0x0102, result!.Value.InstanceSequence);
Assert.Equal((ushort)0x0304, result.Value.MovementSequence);
Assert.Equal((ushort)0x0506, result.Value.ServerControlSequence);
Assert.True(result.Value.IsAutonomous);
}
[Fact]
public void ParsesMoveToObjectTargetGuidAndOrigin()
{
// Type 6 (MoveToObject) prepends a u32 target guid before the
// standard Origin + MovementParameters + runRate payload.
// Body size: 20 (header) + 4 (guid) + 16 (origin) + 28 (params) + 4 (runRate) = 72.
var body = new byte[20 + 4 + 16 + 28 + 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80004321u); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6; // MovementData header padding
body[p++] = 6; // MoveToObject
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001234u); p += 4; // target guid
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4; // cell
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 5f); p += 4; // origin x
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 6f); p += 4; // origin y
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 7f); p += 4; // origin z
const uint flags = 0x1u | 0x2u | 0x200u; // can_walk | can_run | move_towards
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), float.MaxValue); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.57f); p += 4;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; // runRate
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((byte)6, result!.Value.MotionState.MovementType);
Assert.True(result.Value.MotionState.IsServerControlledMoveTo);
Assert.NotNull(result.Value.MotionState.MoveToPath);
var path = result.Value.MotionState.MoveToPath!.Value;
Assert.Equal(0x80001234u, path.TargetGuid);
Assert.Equal(0xA8B4000Eu, path.OriginCellId);
Assert.Equal(5f, path.OriginX);
Assert.Equal(6f, path.OriginY);
Assert.Equal(7f, path.OriginZ);
Assert.Equal(1.25f, result.Value.MotionState.MoveToRunRate);
}
}