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

@ -170,42 +170,62 @@ public sealed class GameWindow : IDisposable
/// motion tables with HasVelocity=0).
/// </para>
/// </summary>
private readonly Dictionary<uint, RemoteDeadReckonState> _remoteDeadReckon = new();
private readonly Dictionary<uint, RemoteMotion> _remoteDeadReckon = new();
private sealed class RemoteDeadReckonState
/// <summary>
/// Per-remote-entity physics + motion stack — verbatim application of
/// retail's client-side motion pipeline to every remote. Mirrors
/// retail <c>FUN_00515020</c> <c>update_object</c> → <c>FUN_00513730</c>
/// <c>UpdatePositionInternal</c> → <c>FUN_005111D0</c>
/// <c>UpdatePhysicsInternal</c>, and ACE's <c>PhysicsObj.cs</c> port.
///
/// <para>
/// Retail has NO special "interpolator" for remote entities — it runs
/// the full motion state machine on every entity, local or remote,
/// and reconciles via hard-snap on UpdatePosition. This class simply
/// pairs a <see cref="AcDream.Core.Physics.PhysicsBody"/> with its
/// <see cref="AcDream.Core.Physics.MotionInterpreter"/> so each
/// remote gets the same treatment as the local player.
/// </para>
/// </summary>
private sealed class RemoteMotion
{
/// <summary>Last server-authoritative world position.</summary>
public AcDream.Core.Physics.PhysicsBody Body;
public AcDream.Core.Physics.MotionInterpreter Motion;
/// <summary>Last UpdatePosition timestamp — drives body.update_object sub-stepping.</summary>
public double LastServerPosTime;
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
public System.Numerics.Vector3 LastServerPos;
/// <summary>When that last server position arrived (UTC).</summary>
public System.DateTime LastServerPosTime;
/// <summary>Last server-authoritative world rotation.</summary>
public System.Numerics.Quaternion LastServerRot = System.Numerics.Quaternion.Identity;
/// <summary>
/// Most recently observed position-delta-based world velocity, used
/// as fallback when the sequencer has no CurrentVelocity. Computed
/// as (pos_new - pos_old) / dt across consecutive UpdatePositions.
/// Legacy field — no longer used for slerp (retail hard-snaps
/// per FUN_00514b90 set_frame). Kept to avoid churn.
/// </summary>
public System.Numerics.Vector3 ObservedVelocity;
/// <summary>Server-supplied world velocity from UpdatePosition (HasVelocity flag).</summary>
public System.Numerics.Vector3? ServerVelocity;
public System.Numerics.Quaternion TargetOrientation = System.Numerics.Quaternion.Identity;
/// <summary>
/// Angular velocity seeded from UpdateMotion TurnCommand/TurnSpeed
/// (π/2 × turnSpeed, signed). Applied per tick to body orientation
/// via manual integration (bypassing <c>PhysicsBody.update_object</c>'s
/// MinQuantum 30fps gate that would otherwise skip most ticks).
/// Zeroed on UM with TurnCommand absent.
/// </summary>
public System.Numerics.Vector3 ObservedOmega;
/// <summary>
/// Internal dead-reckoned position: the authoritative server pos plus
/// velocity*dt integration since the last update. Each tick this
/// advances; on UpdatePosition it resets to the new server pos.
/// Separated from the publicly visible Entity.Position so the
/// residual-decay logic doesn't mix with the integration state.
/// </summary>
public System.Numerics.Vector3 DeadReckonedPos;
/// <summary>
/// Residual offset the renderer is blending out. When UpdatePosition
/// arrives, we compute (lastRenderedPos - newServerPos) and store it
/// here; each tick the offset decays toward zero while the entity's
/// displayed position = DeadReckonedPos + residual. This hides a
/// sudden teleport when the dead-reckoner and server disagreed.
/// </summary>
public System.Numerics.Vector3 SnapResidual;
public RemoteMotion()
{
Body = new AcDream.Core.Physics.PhysicsBody
{
// Remotes don't simulate gravity — server owns Z. Force
// Contact + OnWalkable + Active so apply_current_movement
// writes velocity through every tick (the gate in
// MotionInterpreter.apply_current_movement is
// PhysicsObj.OnWalkable).
State = AcDream.Core.Physics.PhysicsStateFlags.ReportCollisions,
TransientState = AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active,
};
Motion = new AcDream.Core.Physics.MotionInterpreter(Body);
}
}
/// <summary>Soft-snap decay rate (1/sec). At this rate the residual
@ -217,8 +237,18 @@ public sealed class GameWindow : IDisposable
/// update as a teleport / rubber-band and hard-snap (no soft lerp).
/// Prevents the soft-snap logic from trying to smooth a genuine portal
/// or force-move event.
///
/// <para>
/// Matches retail's <c>GetAutonomyBlipDistance</c> (ACE
/// <c>PhysicsObj.cs:545</c>): 20m for creatures, 25m for players.
/// We use 20m as a conservative default — any delta larger than this
/// must be a teleport (portal, recall, spawn). A running character
/// with 1-second UpdatePosition cadence at 9.5 m/s produces deltas
/// of ~9.5m, well below this threshold, so normal movement flows
/// through the interpolation queue instead of hard-snapping.
/// </para>
/// </summary>
private const float SnapHardSnapThreshold = 5.0f;
private const float SnapHardSnapThreshold = 20.0f;
/// <summary>
/// Soft-snap window in seconds: after an UpdatePosition arrives for a
@ -1603,56 +1633,50 @@ public sealed class GameWindow : IDisposable
? (0x80000000u | (uint)stance)
: ae.Sequencer.CurrentStyle;
// If the server told us a command, use it. If command is 0,
// that's "stop / return to idle"; translate that into the
// style's default (typically Ready, 0x41000003 for NonCombat).
// If command is null ("not updated"), keep current motion.
// ACE's stop signal: ForwardCommand flag CLEARED on the wire.
// Per ACE InterpretedMotionState(MovementData) ctor + BuildMovementFlags,
// when the player releases keys the InterpretedMotionState has
// ForwardCommand = Invalid (default) and BuildMovementFlags doesn't
// set bit 0x02 — so the field is absent. Retail's decompiled
// handler (FUN_005295D0 → FUN_0051F260 @ chunk_00510000.c:13957)
// bulk-copies Invalid/0 into the physics obj, which StopCompletely
// treats as "return to style default (Ready)."
//
// command == null → retail stop signal → Ready
// command.Value == 0 → explicit 0 (rare) → Ready
// otherwise → resolve class byte and use full cmd
uint fullMotion;
if (command.HasValue)
if (!command.HasValue || command.Value == 0)
{
if (command.Value == 0)
{
// Stop — pick the style's default substate (Ready).
fullMotion = 0x41000003u;
}
else
{
// Use MotionCommandResolver to restore the proper class
// byte from the wire's 16-bit ForwardCommand. The old
// heuristic (OR the sequencer's current high byte)
// produced wrong values like 0x41000007 instead of the
// real RunForward 0x44000007 — SetCycle's cycleKey
// lookup uses low 24 bits so it'd still hit the right
// MotionData cycle, BUT the class byte gates later
// behaviour (locomotion-motion-detection at line 3615
// uses `motion & 0xFFu`, and the class byte is needed
// for stance-aware code paths).
uint resolved = AcDream.Core.Physics.MotionCommandResolver
.ReconstructFullCommand(command.Value);
fullMotion = resolved != 0
? resolved
: (ae.Sequencer.CurrentMotion & 0xFF000000u) | (uint)command.Value;
if (fullMotion == (uint)command.Value) // no class bits yet
fullMotion = 0x40000000u | (uint)command.Value;
}
// Stop — return to the style's default substate (Ready).
fullMotion = 0x41000003u;
}
else
{
fullMotion = ae.Sequencer.CurrentMotion;
// Use MotionCommandResolver to restore the proper class
// byte from the wire's 16-bit ForwardCommand.
uint resolved = AcDream.Core.Physics.MotionCommandResolver
.ReconstructFullCommand(command.Value);
fullMotion = resolved != 0
? resolved
: (ae.Sequencer.CurrentMotion & 0xFF000000u) | (uint)command.Value;
if (fullMotion == (uint)command.Value) // no class bits yet
fullMotion = 0x40000000u | (uint)command.Value;
}
// ForwardSpeed from the InterpretedMotionState (flag 0x10).
// ForwardSpeed from the InterpretedMotionState (flag 0x04).
// ACE omits this field when speed == 1.0 (only sets the flag
// when ForwardSpeed != 1.0 — see InterpretedMotionState.cs
// BuildMovementFlags L101-L103). So:
// - omitted / 0 → 1.0 (normal speed)
// - present → retail server-broadcast speedMod
// when ForwardSpeed != 1.0 — InterpretedMotionState.cs:101).
// So:
// - field absent → default 1.0 (normal speed)
// - field present → USE THE VALUE, including zero.
//
// The sequencer's SetCycle fast-paths identical (style, motion)
// pairs and calls MultiplyCyclicFramerate when only speedMod
// changed — keeping the loop smooth during a mid-run RunRate
// broadcast.
float speedMod = update.MotionState.ForwardSpeed is { } fs && fs > 0f ? fs : 1f;
// Zero is a VALID stop signal: when the retail client releases
// W, ACE broadcasts WalkForward with ForwardSpeed=0 (via
// apply_run_to_command). Treating zero as "unspecified / 1.0"
// produces "slow walk that never stops" — exactly what the
// stop bug looked like.
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
&& update.Guid != _playerServerGuid)
@ -1685,7 +1709,116 @@ public sealed class GameWindow : IDisposable
}
else
{
ae.Sequencer.SetCycle(fullStyle, fullMotion, speedMod);
// Pick which cycle to play on the sequencer. Priority:
// 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk.
// 2. Else sidestep cmd if active — legs strafe.
// 3. Else turn cmd if active — legs pivot.
// 4. Else Ready — idle.
//
// For forward+sidestep or forward+turn, the forward cycle
// wins at the anim layer; the sidestep/turn contribute via
// MotionInterpreter velocity/omega writes.
uint animCycle = fullMotion;
float animSpeed = speedMod;
uint fwdLow = fullMotion & 0xFFu;
bool fwdIsRunWalk = fwdLow == 0x05 /* Walk */ || fwdLow == 0x06 /* WalkBack */
|| fwdLow == 0x07 /* Run */;
if (!fwdIsRunWalk)
{
// Forward is Ready (or absent). Prefer sidestep cycle if present,
// else turn cycle, else Ready.
if (update.MotionState.SideStepCommand is { } sideForAnim && sideForAnim != 0)
{
uint sideFullForAnim = AcDream.Core.Physics.MotionCommandResolver
.ReconstructFullCommand(sideForAnim);
if (sideFullForAnim == 0) sideFullForAnim = 0x65000000u | sideForAnim;
animCycle = sideFullForAnim;
animSpeed = update.MotionState.SideStepSpeed ?? 1f;
}
else if (update.MotionState.TurnCommand is { } turnForAnim && turnForAnim != 0)
{
uint turnFullForAnim = AcDream.Core.Physics.MotionCommandResolver
.ReconstructFullCommand(turnForAnim);
if (turnFullForAnim == 0) turnFullForAnim = 0x65000000u | turnForAnim;
animCycle = turnFullForAnim;
animSpeed = MathF.Abs(update.MotionState.TurnSpeed ?? 1f);
}
}
ae.Sequencer.SetCycle(fullStyle, animCycle, animSpeed);
// Retail runs the full MotionInterp state machine on every
// remote. Route each wire command (forward, sidestep, turn)
// through DoInterpretedMotion so apply_current_movement →
// get_state_velocity → PhysicsBody.set_local_velocity fires
// on a subsequent tick exactly as retail's FUN_00529210
// (apply_current_movement) does.
//
// Decompile refs:
// FUN_00529930 DoMotion
// FUN_00528f70 DoInterpretedMotion
// FUN_00528960 get_state_velocity
// FUN_00529210 apply_current_movement
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
{
// Forward axis (Ready / WalkForward / RunForward / WalkBackward).
remoteMot.Motion.DoInterpretedMotion(
fullMotion, speedMod, modifyInterpretedState: true);
// Sidestep axis.
if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0)
{
uint sideFull = AcDream.Core.Physics.MotionCommandResolver
.ReconstructFullCommand(sideCmd16);
if (sideFull == 0) sideFull = 0x65000000u | sideCmd16;
float sideSpd = update.MotionState.SideStepSpeed ?? 1f;
remoteMot.Motion.DoInterpretedMotion(
sideFull, sideSpd, modifyInterpretedState: true);
}
else
{
// No sidestep — clear any leftover strafing motion.
remoteMot.Motion.StopInterpretedMotion(
AcDream.Core.Physics.MotionCommand.SideStepRight, modifyInterpretedState: true);
remoteMot.Motion.StopInterpretedMotion(
AcDream.Core.Physics.MotionCommand.SideStepLeft, modifyInterpretedState: true);
}
// Turn axis — and use as the on/off switch for ObservedOmega.
// On turn start: seed ObservedOmega from the formula
// (π/2 × turnSpeed) so rotation begins THIS tick without
// waiting for the next UP to observe a delta.
// On turn end: zero ObservedOmega so rotation stops
// immediately instead of coasting at the last observed
// rate until the next UP shows zero delta.
// UpdatePosition still REFINES the rate from actual
// server deltas (more accurate than the formula), but
// this ensures instant on/off response.
if (update.MotionState.TurnCommand is { } turnCmd16 && turnCmd16 != 0)
{
uint turnFull = AcDream.Core.Physics.MotionCommandResolver
.ReconstructFullCommand(turnCmd16);
if (turnFull == 0) turnFull = 0x65000000u | turnCmd16;
float turnSpd = update.MotionState.TurnSpeed ?? 1f;
remoteMot.Motion.DoInterpretedMotion(
turnFull, turnSpd, modifyInterpretedState: true);
// Seed ObservedOmega with formula so rotation starts
// immediately; UP deltas will refine the rate.
uint turnLow = turnFull & 0xFFu;
if (turnLow == 0x0D /* TurnRight */)
remoteMot.ObservedOmega = new System.Numerics.Vector3(0, 0, -(MathF.PI / 2f) * turnSpd);
else if (turnLow == 0x0E /* TurnLeft */)
remoteMot.ObservedOmega = new System.Numerics.Vector3(0, 0, (MathF.PI / 2f) * turnSpd);
}
else
{
remoteMot.Motion.StopInterpretedMotion(
AcDream.Core.Physics.MotionCommand.TurnRight, modifyInterpretedState: true);
remoteMot.Motion.StopInterpretedMotion(
AcDream.Core.Physics.MotionCommand.TurnLeft, modifyInterpretedState: true);
// Zero ObservedOmega immediately — don't coast.
remoteMot.ObservedOmega = System.Numerics.Vector3.Zero;
}
}
}
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
@ -1715,7 +1848,7 @@ public sealed class GameWindow : IDisposable
if (_remoteLastMove.TryGetValue(update.Guid, out var prev))
_remoteLastMove[update.Guid] = (prev.Pos, refreshedTime);
if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr))
dr.LastServerPosTime = refreshedTime;
dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds;
}
// Route the Commands list — one-shot Actions, Modifiers, and
@ -1862,46 +1995,75 @@ public sealed class GameWindow : IDisposable
_remoteLastMove[update.Guid] = (worldPos, now);
}
// Dead-reckon state: accumulate observed world-space velocity.
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var drState))
// Retail-faithful hard-snap on UpdatePosition.
// Decompile: FUN_00559030 @ chunk_00550000.c:8232 writes
// pos/rot directly into PhysicsObj+0x80..0xBC with no blending.
// Between UpdatePositions, per-tick velocity integration keeps
// the rendered position close to server truth so each snap is
// small. When HasVelocity is set, we also seed PhysicsBody
// velocity (matches retail's set_velocity call in the same
// dispatcher).
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rmState))
{
drState = new RemoteDeadReckonState();
_remoteDeadReckon[update.Guid] = drState;
rmState = new RemoteMotion();
_remoteDeadReckon[update.Guid] = rmState;
// Hard-snap orientation on first spawn so the per-tick
// slerp doesn't visibly rotate from Identity to truth.
rmState.Body.Orientation = rot;
}
else
rmState.Body.Position = worldPos;
// Retail hard-snaps orientation on UpdatePosition (set_frame,
// FUN_00514b90 @ chunk_00510000.c:5637 — direct assignment).
// Rotation rate between UPs comes from the formula-based
// omega seed on UpdateMotion (π/2 × turnSpeed). We tried
// deriving omega from UP deltas, but the first UP after a
// turn starts incorporates the pre-turn interval and produces
// a halved "observed" rate → visible slow-start. Formula-only
// is stable and simple; hard-snap fixes any drift.
rmState.Body.Orientation = rot;
rmState.TargetOrientation = rot;
rmState.LastServerPos = worldPos;
rmState.LastServerPosTime = (now - System.DateTime.UnixEpoch).TotalSeconds;
// Align the body's physics clock with our clock so update_object
// doesn't sub-step a huge initial gap.
rmState.Body.LastUpdateTime = rmState.LastServerPosTime;
// ACE broadcasts UpdatePosition WITHOUT HasVelocity for player
// remote motion — even while actively running. Per packet
// captures: UPs always arrive with velocity null. So we can't
// use UP-absent-velocity as a stop signal (was previously a
// bug that fired StopCompletely every UP → intermittent run).
//
// Stop is signaled by UpdateMotion(ForwardCommand = Ready =
// 0x41000003), handled in OnLiveMotionUpdated. UP's role here
// is just to hard-snap position and adopt velocity IF the
// packet happens to carry one (rare for players, common for
// scripted-path NPCs / missiles).
if (update.Velocity is { } svel)
{
float dtSec = (float)(now - drState.LastServerPosTime).TotalSeconds;
if (dtSec > 0.01f && dtSec < 1.0f)
rmState.Body.Velocity = svel;
// Only use the < 0.2 m/s stop signal when velocity was
// explicitly provided (i.e. server sent HasVelocity + tiny
// value = "I'm definitely stopped"). Absent velocity field
// carries no stop information for our ACE.
if (svel.LengthSquared() < 0.04f)
{
// EMA-smooth the observed velocity so one-off snaps don't
// overwrite the running average. alpha=0.5 converges fast
// but resists single-frame noise.
var observed = (worldPos - drState.LastServerPos) / dtSec;
drState.ObservedVelocity = 0.5f * drState.ObservedVelocity + 0.5f * observed;
rmState.Motion.StopCompletely();
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
&& aeForStop.Sequencer is not null)
{
uint curStyle = aeForStop.Sequencer.CurrentStyle;
uint readyCmd = (curStyle & 0xFF000000u) != 0
? ((curStyle & 0xFF000000u) | 0x01000003u)
: 0x41000003u;
aeForStop.Sequencer.SetCycle(curStyle, readyCmd);
}
}
}
drState.LastServerPos = worldPos;
drState.LastServerRot = rot;
drState.LastServerPosTime = now;
drState.ServerVelocity = update.Velocity;
drState.DeadReckonedPos = worldPos; // reset integration from server truth
// Soft-snap: if the displayed position (preSnapPos) was close to
// the authoritative position, convert the error into a residual
// that decays over ~100ms. If it was far (> SnapHardSnapThreshold),
// this IS a teleport — leave residual zero, hard-snap already done.
var snapError = preSnapPos - worldPos;
float mag = snapError.Length();
if (mag > 1e-3f && mag <= SnapHardSnapThreshold)
{
drState.SnapResidual = snapError;
entity.Position = worldPos + snapError; // keep rendered pos unchanged this frame
}
else
{
drState.SnapResidual = System.Numerics.Vector3.Zero;
// entity.Position already = worldPos from hard-snap above
}
entity.Position = rmState.Body.Position;
entity.Rotation = rmState.Body.Orientation;
}
// Phase B.3: portal-space arrival detection.
@ -3646,21 +3808,16 @@ public sealed class GameWindow : IDisposable
/// </summary>
private void TickAnimations(float dt)
{
// Stop-detection window: if a remote entity is in a locomotion
// cycle but hasn't moved meaningfully in this many ms, swap them
// to Ready. Retail observer pattern — server never broadcasts an
// explicit stop; observer infers from position deltas.
//
// 300ms matches the interval between typical server-broadcast
// UpdatePositions for a stationary NPC (~3-5 Hz heartbeat). Any
// shorter and we'd false-positive between packets; longer and the
// stop animation lags visibly.
const double StopIdleMs = 300.0;
// Additional velocity-based stop detector: if the EMA observed
// velocity drops below this world m/s, the entity has clearly
// stopped. Catches the case where the server IS sending
// UpdatePositions but they're all repeating the same pos.
const float StopVelocityThreshold = 0.2f;
// Retail has NO stop-detection heuristic — it relies on the
// server sending explicit UpdateMotion(Ready). The server-side
// stop signal flows the same way as any other motion change:
// alt releases W → MoveToState(Ready) → ACE broadcasts
// UpdateMotion(Ready) + UpdatePosition with zero velocity.
// On our side, both are handled: UpdateMotion routes through
// MotionInterpreter.DoInterpretedMotion which zeroes the
// interpreter's state, and UpdatePosition's HasVelocity~0 hits
// MotionInterpreter.StopCompletely. Anything else is server
// buggery (packet loss, ACE bug) — don't guess client-side.
var now = System.DateTime.UtcNow;
foreach (var kv in _animatedEntities)
@ -3668,68 +3825,14 @@ public sealed class GameWindow : IDisposable
var ae = kv.Value;
// Locate the server guid for this entity once per tick — needed
// for both stop-detection and dead-reckoning. O(N) reverse
// lookup; for player populations < 100 the cost is negligible.
// for dead-reckoning. O(N) reverse lookup; for player populations
// < 100 the cost is negligible.
uint serverGuid = 0;
foreach (var esg in _entitiesByServerGuid)
{
if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; }
}
// ── Remote stop-detection: if this entity's sequencer is in a
// locomotion cycle and their position hasn't changed in >400ms,
// the retail player stopped moving. Swap them to Ready. This
// replaces the never-arriving "released forward" UpdateMotion.
if (ae.Sequencer is not null)
{
uint motionLo = ae.Sequencer.CurrentMotion & 0xFFu;
bool inLocomotion = motionLo == 0x05 // WalkForward
|| motionLo == 0x06 // WalkBackward
|| motionLo == 0x07 // RunForward
|| motionLo == 0x0F // SideStepRight
|| motionLo == 0x10; // SideStepLeft
if (inLocomotion
&& serverGuid != 0
&& serverGuid != _playerServerGuid)
{
bool shouldStop = false;
// Signal 1: no server-side position change in StopIdleMs.
if (_remoteLastMove.TryGetValue(serverGuid, out var last)
&& (now - last.Time).TotalMilliseconds > StopIdleMs)
{
shouldStop = true;
}
// Signal 2: observed velocity has decayed below threshold.
// This catches the case where UpdatePositions are arriving
// at rate but each one is the same position (server-side
// stationary). EMA keeps the velocity average reflecting
// the current truth.
if (!shouldStop
&& _remoteDeadReckon.TryGetValue(serverGuid, out var dr)
&& (now - dr.LastServerPosTime).TotalMilliseconds < 600.0
&& dr.ObservedVelocity.Length() < StopVelocityThreshold)
{
// Only trigger stop-via-velocity if the sequencer's
// own velocity is also low — otherwise the cycle's
// MotionData has non-zero forward velocity and we'd
// flip-flop (stop → start → stop).
if (ae.Sequencer.CurrentVelocity.Length() < 0.5f)
shouldStop = true;
}
if (shouldStop)
{
uint curStyle = ae.Sequencer.CurrentStyle;
uint ready = (curStyle & 0xFF000000u) != 0
? ((curStyle & 0xFF000000u) | 0x01000003u)
: 0x41000003u;
ae.Sequencer.SetCycle(curStyle, ready);
}
}
}
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
// The server broadcasts UpdatePosition at ~5-10Hz for distant
// entities; without integration, remote chars jitter-hop between
@ -3746,105 +3849,75 @@ public sealed class GameWindow : IDisposable
if (ae.Sequencer is not null
&& serverGuid != 0
&& serverGuid != _playerServerGuid
&& _remoteDeadReckon.TryGetValue(serverGuid, out var drState))
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm))
{
System.Numerics.Vector3 worldVel = System.Numerics.Vector3.Zero;
// Stop detection is handled explicitly on packet receipt:
// - UpdateMotion with ForwardCommand flag CLEARED → Ready.
// - UpdatePosition with HasVelocity flag CLEARED → StopCompletely.
// Both map to retail's "flag-absent = Invalid = reset to
// default" semantics (FUN_0051F260 bulk-copy). No timer-based
// inference needed — the server sends the right signal every
// time a remote stops.
// Priority 1: sequencer's MotionData velocity, rotated into
// world space by the entity's orientation. "World space on
// the object" (r03 §1.3) → local vector rotated by entity
// rotation → world space.
var seqVel = ae.Sequencer.CurrentVelocity;
if (seqVel.LengthSquared() > 1e-6f)
// Retail per-tick motion pipeline applied to every remote.
// Mirrors retail FUN_00515020 update_object → FUN_00513730
// UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal:
//
// 1. apply_current_movement (FUN_00529210) — recomputes
// body.Velocity from InterpretedState via get_state_velocity.
// 2. Pull omega from the sequencer (baked MotionData.Omega
// for TurnRight / TurnLeft cycles, scaled by speedMod).
// 3. body.update_object(now) — Euler-integrates
// position += Velocity × dt + 0.5 × Accel × dt² AND
// orientation += omega × dt.
//
// On UpdatePosition receipt we hard-snap body.Position and
// body.Orientation — if integration matched server physics,
// each snap is small/invisible.
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
// Step 1: re-apply current motion commands → body.Velocity.
// Forces OnWalkable + Contact so the gate in apply_current_movement
// always succeeds (remotes are server-authoritative; we don't
// simulate airborne physics for them).
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active;
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
// Step 2: integrate rotation manually per tick. We can't
// rely on PhysicsBody.update_object here — its MinQuantum
// gate (1/30 s) causes it to SKIP integration when our
// 60fps render dt (~0.016s) is below the quantum, meaning
// rotation never advances. Measured snap per UP was ~129°
// = the full expected 1s × 2.24 rad/s, confirming zero
// between-tick rotation.
//
// Manual integration matches retail's FUN_005256b0
// apply_physics (Orientation *= quat(ω × dt)). Use
// ObservedOmega derived from server UP rotation deltas so
// the rate exactly matches server physics — hard-snap on
// next UP becomes invisible by construction.
rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object
if (rm.ObservedOmega.LengthSquared() > 1e-8f)
{
worldVel = System.Numerics.Vector3.Transform(seqVel, ae.Entity.Rotation);
}
// Priority 2: server-supplied world velocity (HasVelocity flag
// on UpdatePosition). Already world-space; no rotation.
else if (drState.ServerVelocity is { } sv && sv.LengthSquared() > 1e-6f)
{
worldVel = sv;
}
// Priority 3: EMA-observed velocity from position deltas.
// Fallback for NPC motion tables with HasVelocity=0 (dat
// authors didn't encode it). Already world-space.
else if (drState.ObservedVelocity.LengthSquared() > 1e-6f
&& (now - drState.LastServerPosTime).TotalMilliseconds < 2000.0)
{
worldVel = drState.ObservedVelocity;
float omegaMag = rm.ObservedOmega.Length();
var axis = rm.ObservedOmega / omegaMag;
float angle = omegaMag * dt;
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
}
if (worldVel.LengthSquared() > 1e-6f)
{
// Only integrate while the cycle is a locomotion cycle.
// Idle (Ready 0x03) and emotes should stay pinned at the
// last server pos — MotionData for Ready has no velocity
// anyway, but belt + suspenders.
uint mlo = ae.Sequencer.CurrentMotion & 0xFFu;
bool isLocomotion = mlo == 0x05 || mlo == 0x06
|| mlo == 0x07
|| mlo == 0x0F || mlo == 0x10;
if (isLocomotion)
{
// Integrate from the separate DeadReckonedPos — NOT
// from Entity.Position, which may be carrying a
// decaying soft-snap residual. This keeps the
// integration clean and the residual applied as a
// pure render-time offset.
var predicted = drState.DeadReckonedPos + worldVel * dt;
float maxDrift = worldVel.Length() * DeadReckonMaxPredictSeconds;
var fromServer = predicted - drState.LastServerPos;
if (fromServer.LengthSquared() > maxDrift * maxDrift && maxDrift > 1e-3f)
{
// Clamp back toward last server position.
var clamped = drState.LastServerPos +
System.Numerics.Vector3.Normalize(fromServer) * maxDrift;
drState.DeadReckonedPos = clamped;
}
else
{
drState.DeadReckonedPos = predicted;
}
}
}
// Step 3: integrate physics — retail FUN_00515020
// update_object → FUN_00513730 UpdatePositionInternal →
// FUN_005256b0 Sequence::apply_physics. Position and
// orientation BOTH advance from Velocity/Omega × dt.
// No slerp, no soft-snap — retail is deterministic.
rm.Body.update_object(nowSec);
// Render position = dead-reckoned authoritative + residual.
// Residual decays toward zero, so after ~300ms the rendered
// position matches the authoritative truth.
float decay = MathF.Max(0f, 1f - SnapResidualDecayRate * dt);
drState.SnapResidual *= decay;
if (drState.SnapResidual.LengthSquared() < 1e-4f)
drState.SnapResidual = System.Numerics.Vector3.Zero;
ae.Entity.Position = drState.DeadReckonedPos + drState.SnapResidual;
// Rotation integration: if the sequencer's Omega is non-zero
// (TurnRight / TurnLeft / any cycle with baked-in spin), rotate
// the entity's quaternion around the omega axis by |omega|*dt.
// Matches ACE Sequence.apply_physics L221-L229:
// frame.Rotate(Omega * quantum)
// where frame.Rotate treats the argument as a local-axis
// rotation. Only kicks in for Turn cycles (low byte 0x0D/0x0E)
// — other motions either have zero omega or integrate rotation
// server-side.
var seqOmega = ae.Sequencer.CurrentOmega;
if (seqOmega.LengthSquared() > 1e-6f)
{
uint mlo2 = ae.Sequencer.CurrentMotion & 0xFFu;
bool isTurning = mlo2 == 0x0D || mlo2 == 0x0E; // TurnRight / TurnLeft
if (isTurning)
{
// Omega as a scaled axis-angle. Build a delta quaternion
// and compose it on the entity's current rotation.
float angle = seqOmega.Length() * dt;
if (angle > 1e-5f)
{
var axis = System.Numerics.Vector3.Normalize(seqOmega);
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
ae.Entity.Rotation = System.Numerics.Quaternion.Normalize(
ae.Entity.Rotation * deltaRot);
}
}
}
ae.Entity.Position = rm.Body.Position;
ae.Entity.Rotation = rm.Body.Orientation;
}
// ── Get per-part (origin, orientation) from either sequencer or legacy ──

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
{

View file

@ -486,6 +486,34 @@ public sealed class AnimationSequencer
if (yvel != 0f || xvel != 0f)
CurrentVelocity = new Vector3(xvel, yvel, 0f);
}
// ── Synthesize CurrentOmega for turn cycles ───────────────────────
// Same story as velocity synthesis above: Humanoid turn MotionData
// often ships without HasOmega. Retail clients turn the body via
// the baked omega, but if the dat is silent we fall back to the
// retail turn-rate constant. Decompile references:
// FUN_00529210 apply_current_movement (writes Omega)
// chunk_00520000.c TurnRate globals (~π/2 rad/s for speed=1)
// The ACE port uses `omega.z = ±(π/2) × turnSpeed` for right/left
// turns (holtburger confirms the same via motion_resolution.rs).
if (CurrentOmega.LengthSquared() < 1e-9f)
{
float zomega = 0f;
uint low = motion & 0xFFu;
switch (low)
{
case 0x0D: // TurnRight — clockwise from above = -Z in right-handed.
zomega = -(MathF.PI / 2f) * adjustedSpeed;
break;
case 0x0E: // TurnLeft — counter-clockwise = +Z. adjust_motion
// may have remapped 0x0E → 0x0D with negated speed;
// in that case the negation preserves correct sign.
zomega = (MathF.PI / 2f) * adjustedSpeed;
break;
}
if (zomega != 0f)
CurrentOmega = new Vector3(0f, 0f, zomega);
}
}
// Retail locomotion constants — mirror of MotionInterpreter.RunAnimSpeed

View file

@ -113,7 +113,11 @@ public class UpdateMotionTests
[Fact]
public void ParsesForwardSpeed_WhenSpeedFlagSet()
{
// Flags = CurrentStyle | ForwardCommand | ForwardSpeed (0x1|0x2|0x10 = 0x13)
// 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;
@ -124,7 +128,7 @@ public class UpdateMotionTests
body[p++] = 0;
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x13u); p += 4;
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