feat(anim): full retail remote-entity motion port — walk/run/strafe/turn/stop

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>
This commit is contained in:
Erik 2026-04-19 21:26:23 +02:00
parent 00c8a4feb5
commit 340dabbc72
5 changed files with 506 additions and 303 deletions

View file

@ -110,11 +110,33 @@ public static class CreateObject
/// Nullified Statue of a Drudge, which is rendered in the wrong pose
/// if you only consult the MotionTable's default style.
/// </summary>
/// <summary>
/// Full InterpretedMotionState from the server. Covers every field that
/// can appear in the wire — the earlier version only tracked
/// ForwardCommand/ForwardSpeed and silently discarded TurnCommand /
/// SideStepCommand / their speeds. That made it impossible to render
/// smooth circles or strafing for remote entities — the client literally
/// had no rotation-intent data between UpdatePositions.
///
/// <para>
/// Per ACE <c>InterpretedMotionState.Write</c> (line 127) the wire
/// order is: CurrentStyle, ForwardCommand, SideStepCommand,
/// TurnCommand (all ushort), then ForwardSpeed, SideStepSpeed, TurnSpeed
/// (all float). Flag bits (MovementStateFlag enum):
/// 0x01=CurrentStyle, 0x02=ForwardCommand, 0x04=ForwardSpeed,
/// 0x08=SideStepCommand, 0x10=SideStepSpeed, 0x20=TurnCommand,
/// 0x40=TurnSpeed.
/// </para>
/// </summary>
public readonly record struct ServerMotionState(
ushort Stance,
ushort? ForwardCommand,
float? ForwardSpeed = null,
IReadOnlyList<MotionItem>? Commands = null);
IReadOnlyList<MotionItem>? Commands = null,
ushort? SideStepCommand = null,
float? SideStepSpeed = null,
ushort? TurnCommand = null,
float? TurnSpeed = null);
/// <summary>
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
@ -501,6 +523,10 @@ public static class CreateObject
ushort? forwardCommand = null;
float? forwardSpeed = null;
ushort? sidestepCommand = null;
float? sidestepSpeed = null;
ushort? turnCommand = null;
float? turnSpeed = null;
List<MotionItem>? commands = null;
// 0 = Invalid is the only union variant we care about for static
@ -520,36 +546,69 @@ public static class CreateObject
uint flags = packed & 0x7Fu; // MovementStateFlag bits live in low 7 bits
uint numCommands = packed >> 7;
// CurrentStyle (0x1)
// Flag-bit + write order per ACE
// InterpretedMotionState.Write @ line 127
// (MovementStateFlag enum @ ACE.Entity.Enum):
// CurrentStyle = 0x01 (ushort)
// ForwardCommand = 0x02 (ushort)
// SideStepCommand = 0x08 (ushort)
// TurnCommand = 0x20 (ushort)
// ForwardSpeed = 0x04 (float)
// SideStepSpeed = 0x10 (float)
// TurnSpeed = 0x40 (float)
// Note the bit values are NOT in write order — commands
// come first in the wire stream regardless of bit value,
// then speeds. Earlier versions had this mapping wrong,
// which caused ForwardSpeed to silently never be read
// (appeared as HasValue=False on every remote broadcast).
if ((flags & 0x1u) != 0)
{
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
p += 2;
}
// ForwardCommand (0x2)
if ((flags & 0x2u) != 0)
{
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
p += 2;
}
// SidestepCommand (0x4) — skip
if ((flags & 0x4u) != 0) { if (mv.Length - p < 2) goto done; p += 2; }
// TurnCommand (0x8) — skip
if ((flags & 0x8u) != 0) { if (mv.Length - p < 2) goto done; p += 2; }
// ForwardSpeed (0x10)
if ((flags & 0x10u) != 0)
// SideStepCommand (bit 0x8, ushort)
if ((flags & 0x8u) != 0)
{
if (mv.Length - p < 2) goto done;
sidestepCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
p += 2;
}
// TurnCommand (bit 0x20, ushort)
if ((flags & 0x20u) != 0)
{
if (mv.Length - p < 2) goto done;
turnCommand = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
p += 2;
}
// ForwardSpeed (bit 0x4, float)
if ((flags & 0x4u) != 0)
{
if (mv.Length - p < 4) goto done;
forwardSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p));
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; }
// SideStepSpeed (bit 0x10, float)
if ((flags & 0x10u) != 0)
{
if (mv.Length - p < 4) goto done;
sidestepSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p));
p += 4;
}
// TurnSpeed (bit 0x40, float)
if ((flags & 0x40u) != 0)
{
if (mv.Length - p < 4) goto done;
turnSpeed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p));
p += 4;
}
// Commands list: numCommands × 8-byte MotionItem (u16 cmd +
// u16 packedSeq + f32 speed). One-shot actions, emotes,
@ -571,7 +630,9 @@ public static class CreateObject
done:;
}
return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands);
return new ServerMotionState(
currentStyle, forwardCommand, forwardSpeed, commands,
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed);
}
catch
{

View file

@ -123,6 +123,10 @@ public static class UpdateMotion
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)
@ -137,37 +141,68 @@ public static class UpdateMotion
uint flags = packed & 0x7Fu;
uint numCommands = packed >> 7;
// CurrentStyle (0x1) — prefer the InterpretedMotionState's copy
// if present, matching the CreateObject parser's behavior.
// 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;
}
// ForwardCommand (0x2)
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 (0x4) — skip
if ((flags & 0x4u) != 0) { if (body.Length - pos < 2) goto done; pos += 2; }
// TurnCommand (0x8) — skip
if ((flags & 0x8u) != 0) { if (body.Length - pos < 2) goto done; pos += 2; }
// ForwardSpeed (0x10)
if ((flags & 0x10u) != 0)
// 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 (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; }
// 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.
@ -187,7 +222,9 @@ public static class UpdateMotion
done:;
}
return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands));
return new Parsed(guid, new CreateObject.ServerMotionState(
currentStyle, forwardCommand, forwardSpeed, commands,
sidestepCommand, sidestepSpeed, turnCommand, turnSpeed));
}
catch
{