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:
parent
00c8a4feb5
commit
340dabbc72
5 changed files with 506 additions and 303 deletions
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue