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>
234 lines
11 KiB
C#
234 lines
11 KiB
C#
using System.Buffers.Binary;
|
|
using System.Collections.Generic;
|
|
|
|
namespace AcDream.Core.Net.Messages;
|
|
|
|
/// <summary>
|
|
/// Inbound <c>UpdateMotion</c> GameMessage (opcode <c>0xF74C</c>). The server
|
|
/// sends this whenever an already-spawned entity changes its motion state —
|
|
/// NPCs starting a walk cycle, creatures switching to attack stance, doors
|
|
/// opening, a player waving, etc. acdream's animation system needs to
|
|
/// consume these so the motion tick can switch the entity's cycle to the
|
|
/// new (stance, forward-command) pair instead of sitting on whatever the
|
|
/// initial CreateObject said.
|
|
///
|
|
/// <para>
|
|
/// Wire layout (see
|
|
/// <c>references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessageUpdateMotion.cs</c>
|
|
/// and <c>references/ACE/Source/ACE.Server/Network/Motion/MovementData.cs::Write</c>
|
|
/// with <c>header = true</c>):
|
|
/// </para>
|
|
/// <list type="bullet">
|
|
/// <item><b>u32 opcode</b> — 0xF74C</item>
|
|
/// <item><b>u32 objectGuid</b> — which entity this update is for</item>
|
|
/// <item><b>u16 instanceSequence</b> — Sequences.ObjectInstance, tracked but not used for pose</item>
|
|
/// <item><b>MovementData with header</b>:
|
|
/// <list type="bullet">
|
|
/// <item>u16 movementSequence</item>
|
|
/// <item>u16 serverControlSequence</item>
|
|
/// <item>u8 isAutonomous, then align to 4 bytes</item>
|
|
/// <item>u8 movementType</item>
|
|
/// <item>u8 motionFlags</item>
|
|
/// <item>u16 currentStyle (MotionStance)</item>
|
|
/// <item>InterpretedMotionState when movementType == Invalid (0):
|
|
/// u32 flagsAndCommandCount, then each present field in flag order
|
|
/// (CurrentStyle u16, ForwardCommand u16, SidestepCommand u16,
|
|
/// TurnCommand u16, forward speed f32, sidestep speed f32,
|
|
/// turn speed f32), commands list, align.</item>
|
|
/// </list>
|
|
/// </item>
|
|
/// </list>
|
|
///
|
|
/// <para>
|
|
/// We only extract the two fields the animation system actually consumes:
|
|
/// the current <c>Stance</c> and the <c>ForwardCommand</c>. Everything else
|
|
/// is skipped. The outer message doesn't carry a length for MovementData,
|
|
/// so our parser reads exactly as far as it needs and leaves subsequent
|
|
/// bytes untouched.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class UpdateMotion
|
|
{
|
|
public const uint Opcode = 0xF74Cu;
|
|
|
|
/// <summary>
|
|
/// Extracted payload: the guid of the entity whose motion changed and
|
|
/// the (stance, forward-command) pair describing its new pose. The
|
|
/// command is nullable because the <c>ForwardCommand</c> flag may be
|
|
/// unset in the InterpretedMotionState; the stance is always present
|
|
/// (even if 0, meaning "no specific stance").
|
|
/// </summary>
|
|
public readonly record struct Parsed(
|
|
uint Guid,
|
|
CreateObject.ServerMotionState MotionState);
|
|
|
|
/// <summary>
|
|
/// Parse a reassembled UpdateMotion body. <paramref name="body"/> must
|
|
/// start with the 4-byte opcode. Returns null on malformed input
|
|
/// (truncated fields, wrong opcode, malformed InterpretedMotionState).
|
|
/// </summary>
|
|
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
|
{
|
|
try
|
|
{
|
|
int pos = 0;
|
|
|
|
if (body.Length - pos < 4) return null;
|
|
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
if (opcode != Opcode) return null;
|
|
|
|
if (body.Length - pos < 4) return null;
|
|
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
|
|
// ObjectInstance sequence (u16) — tracked but not used for pose.
|
|
if (body.Length - pos < 2) return null;
|
|
pos += 2;
|
|
|
|
// MovementData header: u16 movementSequence, u16 serverControlSequence,
|
|
// u8 isAutonomous, then Align().
|
|
//
|
|
// ACE's Align() (Network/Extensions.cs:55) uses
|
|
// CalculatePadMultiple(BaseStream.Length, 4) — i.e. it pads based on
|
|
// the ABSOLUTE stream length, not a relative offset within the
|
|
// MovementData block.
|
|
//
|
|
// At this point the absolute stream has: opcode (4) + guid (4) +
|
|
// objectInstance (2) + movSeq (2) + srvSeq (2) + isAut (1) = 15.
|
|
// Align(4) rounds 15 → 16, so ONE pad byte is written.
|
|
// MovementData header = 2+2+1+1 = 6 bytes.
|
|
//
|
|
// Previous version mistakenly reserved 8 bytes here, which shifted
|
|
// every subsequent field by 2 and made every remote-char UpdateMotion
|
|
// decode as garbage (stance read from the packed-flags dword).
|
|
if (body.Length - pos < 6) return null;
|
|
pos += 6;
|
|
|
|
// movementType u8, motionFlags u8, currentStyle u16
|
|
if (body.Length - pos < 4) return null;
|
|
byte movementType = body[pos]; pos += 1;
|
|
byte _motionFlags = body[pos]; pos += 1;
|
|
ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
|
pos += 2;
|
|
|
|
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
|
{
|
|
int preHex = Math.Min(body.Length, 32);
|
|
var hex = new System.Text.StringBuilder();
|
|
for (int i = 0; i < preHex; i++) hex.Append($"{body[i]:X2} ");
|
|
System.Console.WriteLine(
|
|
$" UM raw: mt=0x{movementType:X2} mf=0x{_motionFlags:X2} cs=0x{currentStyle:X4} | {hex}");
|
|
}
|
|
|
|
ushort? forwardCommand = null;
|
|
float? forwardSpeed = null;
|
|
ushort? sidestepCommand = null;
|
|
float? sidestepSpeed = null;
|
|
ushort? turnCommand = null;
|
|
float? turnSpeed = null;
|
|
List<CreateObject.MotionItem>? commands = null;
|
|
|
|
if (movementType == 0)
|
|
{
|
|
// InterpretedMotionState — same layout as in CreateObject's
|
|
// MovementInvalid branch, just reached via the header'd path.
|
|
// Includes the Commands list (MotionItem[]) that carries
|
|
// Actions, emotes, and other one-shots not in ForwardCommand.
|
|
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
|
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
uint flags = packed & 0x7Fu;
|
|
uint numCommands = packed >> 7;
|
|
|
|
// Flag-bit layout + write order (ACE
|
|
// InterpretedMotionState.Write @ line 127 + MovementStateFlag
|
|
// enum — note the bit values are NOT in write order):
|
|
// CurrentStyle = 0x01 written first (ushort)
|
|
// ForwardCommand = 0x02 written second (ushort)
|
|
// SideStepCommand = 0x08 written third (ushort)
|
|
// TurnCommand = 0x20 written fourth (ushort)
|
|
// ForwardSpeed = 0x04 written fifth (float)
|
|
// SideStepSpeed = 0x10 written sixth (float)
|
|
// TurnSpeed = 0x40 written seventh (float)
|
|
// Our earlier version had the bit-to-field mapping wrong
|
|
// (treated Side/Turn commands as floats and ForwardSpeed as
|
|
// the wrong bit) — that's why every remote's ForwardSpeed
|
|
// was reading as "absent" (HasValue=False).
|
|
|
|
if ((flags & 0x1u) != 0)
|
|
{
|
|
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
|
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
|
pos += 2;
|
|
}
|
|
if ((flags & 0x2u) != 0)
|
|
{
|
|
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null));
|
|
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
|
pos += 2;
|
|
}
|
|
// SideStepCommand — ushort, bit 0x8
|
|
if ((flags & 0x8u) != 0)
|
|
{
|
|
if (body.Length - pos < 2) goto done;
|
|
sidestepCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
|
pos += 2;
|
|
}
|
|
// TurnCommand — ushort, bit 0x20
|
|
if ((flags & 0x20u) != 0)
|
|
{
|
|
if (body.Length - pos < 2) goto done;
|
|
turnCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
|
pos += 2;
|
|
}
|
|
// ForwardSpeed — float, bit 0x4
|
|
if ((flags & 0x4u) != 0)
|
|
{
|
|
if (body.Length - pos < 4) goto done;
|
|
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
}
|
|
// SideStepSpeed — float, bit 0x10
|
|
if ((flags & 0x10u) != 0)
|
|
{
|
|
if (body.Length - pos < 4) goto done;
|
|
sidestepSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
pos += 4;
|
|
}
|
|
// TurnSpeed — float, bit 0x40
|
|
if ((flags & 0x40u) != 0)
|
|
{
|
|
if (body.Length - pos < 4) goto done;
|
|
turnSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
|
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:;
|
|
}
|
|
|
|
return new Parsed(guid, new CreateObject.ServerMotionState(
|
|
currentStyle, forwardCommand, forwardSpeed, commands,
|
|
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed));
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
}
|