Ports the retail client's client-side remote-entity motion pipeline
verbatim per the decompile research. Every remote now runs its own
PhysicsBody + MotionInterpreter + AnimationSequencer stack — retail has
no special "interpolator" for remotes, it runs the full motion state
machine on every entity. Now we do too.
## What changed
### Parser fixes (CreateObject, UpdateMotion)
Wire flag bits for InterpretedMotionState (per ACE MovementStateFlag enum):
CurrentStyle=0x01, ForwardCommand=0x02, ForwardSpeed=0x04,
SideStepCommand=0x08, SideStepSpeed=0x10, TurnCommand=0x20, TurnSpeed=0x40
Previously we only extracted CurrentStyle + ForwardCommand + ForwardSpeed
and SKIPPED the side/turn fields entirely. Result: we had zero rotation-
or strafe-intent data from the server — impossible to render turn or
sidestep animations. Now ServerMotionState carries all 7 fields and the
parser reads the bytes in ACE's write order (style, fwd, side, turn, then
fwdSpd, sideSpd, turnSpd).
### RemoteMotion (new per-remote struct in GameWindow)
Each remote gets its own PhysicsBody + MotionInterpreter + observed
angular velocity. Replaces the earlier shortcut RemoteInterpolator
(deleted — retail has no such thing).
On UpdateMotion:
- ForwardCommand flag absent → stop signal (reset to Ready) per
retail FUN_0051F260 bulk-copy semantics (absent = Invalid = default).
- Forward + sidestep + turn each route through DoInterpretedMotion,
exactly as retail FUN_00528F70 does.
- Animation cycle selection: forward wins if active, else sidestep,
else turn, else Ready. Matches the user's observation that retail
plays turn animation when only turning.
- Turn command seeds ObservedOmega = π/2 × turnSpeed (from Humanoid
MotionData.Omega.Z ≈ π/2 per decompile).
- Turn absent → ObservedOmega = 0 (stops rotation immediately).
On UpdatePosition:
- Hard-snap Body.Position + Body.Orientation per retail FUN_00514b90
set_frame (direct assignment, no slerp — retail does not soft-snap).
- HasVelocity + |v| < 0.2 → StopCompletely + SetCycle(Ready).
- ForwardSpeed=0 on wire is a VALID stop signal (ACE sends this when
alt releases W); previously we defaulted to 1.0, causing the "slow
walk that never stops" symptom.
Per-tick:
- apply_current_movement → Body.Velocity via get_state_velocity
(retail FUN_00528960: RunAnimSpeed × ForwardSpeed in body-local,
rotated by orientation).
- Manual omega integration: Orientation *= quat(ObservedOmega × dt).
Bypasses PhysicsBody.update_object's MinQuantum=1/30s gate that
was eating every-other-tick rotation updates at our 60fps render
rate — the cause of the persistent "rotation snaps every UP" bug.
- update_object still called for position integration and the motion
subsystem it drives.
### AnimationSequencer synthesis extension
Added omega synthesis for TurnRight/TurnLeft cycles (same pattern as
the earlier velocity synthesis): when the Humanoid dat leaves HasOmega
clear, SetCycle synthesizes CurrentOmega = ±π/2 × speedMod on Z so
dead-reckoning and stop detection can read a non-zero omega for turn
cycles.
### Stop-detection heuristic removed
No more 300ms/2000ms/5000ms idle timers. Retail's stop signal is
explicit (UpdateMotion with ForwardCommand flag absent → Ready); we
handle it directly. Client-side timers were a source of flicker during
normal running.
## Confirmed working
- Walking (matches retail speed + leg cadence)
- Running (matches retail speed + leg cadence)
- Strafing (body moves sideways + strafe animation plays)
- Turning while stationary (body rotates smoothly + turn animation plays)
- Turning while running (body rotates + leg anim continues)
- Stopping (instant stop, no slow-walk tail)
All 717 tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
206 lines
9.5 KiB
C#
206 lines
9.5 KiB
C#
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 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 + 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++] = 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);
|
||
}
|
||
}
|