merge: animation overhaul branch (Opus agent, 10 commits, +32 tests)

Resolves remote-chars-lagging-forward, no-anim-speed-scaling, and
monster/NPC Commands-list (waves/attacks/deaths) not animating.
Adds dead-reckoning + sequence-wide velocity/omega + Commands[]
list parsing + MotionCommandResolver + soft-snap residual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:50:47 +02:00
commit 862cd5662f
8 changed files with 1273 additions and 46 deletions

View file

@ -150,6 +150,84 @@ public sealed class GameWindow : IDisposable
private readonly Dictionary<uint, (System.Numerics.Vector3 Pos, System.DateTime Time)>
_remoteLastMove = new();
/// <summary>
/// Per-remote-entity dead-reckoning state for smoothing between server
/// UpdatePosition broadcasts. Without this, remote characters teleport
/// every ~100200 ms when the server pushes a new position (the retail
/// client hides the gap by integrating <c>CMotionInterp</c>-surfaced
/// velocity forward each tick — see chunk_00520000.c
/// <c>apply_current_movement</c> L7132-L7189 and holtburger's
/// <c>spatial/physics.rs::project_pose_by_velocity</c>).
///
/// <para>
/// Each entry records the last authoritative server position + time + a
/// measured velocity inferred from the delta between consecutive
/// UpdatePositions. The client's per-tick integrator uses the
/// sequencer's <c>CurrentVelocity</c> (rotated into world space by the
/// entity's orientation) as the primary source and falls back to the
/// inferred velocity when the motion table doesn't carry one (e.g. NPC
/// motion tables with HasVelocity=0).
/// </para>
/// </summary>
private readonly Dictionary<uint, RemoteDeadReckonState> _remoteDeadReckon = new();
private sealed class RemoteDeadReckonState
{
/// <summary>Last server-authoritative world position.</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.
/// </summary>
public System.Numerics.Vector3 ObservedVelocity;
/// <summary>Server-supplied world velocity from UpdatePosition (HasVelocity flag).</summary>
public System.Numerics.Vector3? ServerVelocity;
/// <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;
}
/// <summary>Soft-snap decay rate (1/sec). At this rate the residual
/// halves every 1/rate seconds. 8.0 → ~100ms half-life, so even a
/// 2m residual fades within ~300ms without visible snap.</summary>
private const float SnapResidualDecayRate = 8.0f;
/// <summary>
/// When the prediction error exceeds this many meters, we treat the
/// 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.
/// </summary>
private const float SnapHardSnapThreshold = 5.0f;
/// <summary>
/// Soft-snap window in seconds: after an UpdatePosition arrives for a
/// remote entity, dead-reckoning continues but the "origin" for
/// predicted position is the server pos. This matches retail's snap
/// behavior — the server is authoritative, we just interpolate between
/// authoritative samples.
/// </summary>
private const float DeadReckonMaxPredictSeconds = 1.0f;
// Phase F.1-H.1 — client-side state classes fed by GameEventWiring.
// Exposed publicly so plugins + UI panels can bind directly.
public readonly AcDream.Core.Chat.ChatLog Chat = new();
@ -169,6 +247,7 @@ public sealed class GameWindow : IDisposable
private bool _playerMode;
private uint _playerServerGuid;
private uint? _playerCurrentAnimCommand;
private float _playerCurrentAnimSpeed = 1f;
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
// Accumulated mouse X delta for player turning; written in mouse-move
// callback, consumed + reset in OnUpdate each frame.
@ -820,6 +899,12 @@ public sealed class GameWindow : IDisposable
_animatedEntities.Remove(existingEntity.Id);
// Physics collision registry entry is keyed by local id too.
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
// Dead-reckon state is keyed by SERVER guid (not local id) so we
// clear using the same guid the new spawn will use. Leaving old
// SnapResidual / DeadReckonedPos in would make the next first
// UpdatePosition look like a 2m-residual soft-snap.
_remoteDeadReckon.Remove(spawn.Guid);
_remoteLastMove.Remove(spawn.Guid);
}
// Log every spawn that arrives so we can inventory what the server
@ -1399,13 +1484,80 @@ public sealed class GameWindow : IDisposable
fullMotion = ae.Sequencer.CurrentMotion;
}
// ForwardSpeed from the InterpretedMotionState (flag 0x10).
// 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
//
// 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;
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
&& update.Guid != _playerServerGuid)
Console.WriteLine(
$"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8})");
$"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8}, speed={speedMod:F2})");
// No-op if same; the sequencer's fast path guards against that.
ae.Sequencer.SetCycle(fullStyle, fullMotion);
ae.Sequencer.SetCycle(fullStyle, fullMotion, speedMod);
// Route the Commands list — one-shot Actions, Modifiers, and
// ChatEmotes (mask classes 0x10, 0x20, 0x13 per r03 §3). These
// live in the motion table's Links / Modifiers dicts, not
// Cycles, and are played on top of the current cycle via
// PlayAction which resolves the right dict and interleaves the
// action frames before the cyclic tail.
//
// A typical NPC wave looks like:
// ForwardCommand=0 (Ready) + Commands=[{0x0087=Wave, speed=1.0}]
// [{0x0003=Ready, ...}]
// Each item runs through PlayAction (for 0x10/0x20 mask) or the
// standard SetCycle path (for 0x40 SubState). We leave SubState
// commands to fall through to the next UpdateMotion; that's how
// retail handles transition sequences (Wave → Ready).
if (update.MotionState.Commands is { Count: > 0 } cmds)
{
foreach (var item in cmds)
{
// Restore the 32-bit MotionCommand from the wire's 16-bit
// truncation by OR-ing class bits. The class is encoded
// in the low byte's high nibble via command ranges:
// 0x0003, 0x0005-0x0010 — SubState class (0x40xx xxxx)
// 0x0087 (Wave), 0x007D (BowDeep) etc — ChatEmote (0x13xx xxxx)
// 0x0051-0x00A1 — Action class (0x10xx xxxx)
//
// The retail MotionCommand enum carries the class byte in
// bits 24-31. DatReaderWriter's enum values match. For
// broadcasts, servers emit only low 16 bits (ACE
// InterpretedMotionState.cs:139). We reconstruct via a
// range-based lookup. See MotionCommand.generated.cs.
uint fullCmd = AcDream.Core.Physics.MotionCommandResolver.ReconstructFullCommand(item.Command);
if (fullCmd == 0) continue;
// Action class: play through the link dict then drop back
// to the current cycle. Modifier class: resolve from the
// Modifiers dict and combine on top. SubState: cycle
// change; route through SetCycle so the style-specific
// cycle fallback applies.
uint cls = fullCmd & 0xFF000000u;
if ((cls & 0x10000000u) != 0 || (cls & 0x20000000u) != 0
|| cls == 0x12000000u || cls == 0x13000000u)
{
ae.Sequencer.PlayAction(fullCmd, item.Speed);
}
else if ((cls & 0x40000000u) != 0)
{
// Substate in the command list — typically the "and
// then return to Ready" item. Update the cycle.
ae.Sequencer.SetCycle(fullStyle, fullCmd, item.Speed);
}
// else: Style / UI / Toggle class — not animation-driving.
}
}
return;
}
@ -1463,6 +1615,12 @@ public sealed class GameWindow : IDisposable
var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin;
var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
// Capture the pre-update render position for the soft-snap residual
// calculation below. Assign entity.Position to the server truth up
// front; if we then compute a snap residual, we restore the rendered
// position by adding the residual back (so the visual doesn't jerk
// for one frame before the residual decay kicks in on the next tick).
System.Numerics.Vector3 preSnapPos = entity.Position;
entity.Position = worldPos;
entity.Rotation = rot;
@ -1470,18 +1628,66 @@ public sealed class GameWindow : IDisposable
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates
// that report the same position keep the old Time, so the
// TickAnimations check can see when motion last changed.
//
// Also populate the dead-reckon state so TickAnimations can
// integrate velocity between server updates and avoid teleport jitter.
// Observed-velocity is computed from the position delta across
// consecutive updates — this is the fallback when the motion table's
// MotionData.Velocity is zero (NPCs without HasVelocity).
if (update.Guid != _playerServerGuid)
{
var now = System.DateTime.UtcNow;
if (_remoteLastMove.TryGetValue(update.Guid, out var prev))
{
float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos);
if (moveDist > 0.05f)
_remoteLastMove[update.Guid] = (worldPos, System.DateTime.UtcNow);
_remoteLastMove[update.Guid] = (worldPos, now);
// else: leave old entry so "Time" = last real movement time
}
else
{
_remoteLastMove[update.Guid] = (worldPos, System.DateTime.UtcNow);
_remoteLastMove[update.Guid] = (worldPos, now);
}
// Dead-reckon state: accumulate observed world-space velocity.
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var drState))
{
drState = new RemoteDeadReckonState();
_remoteDeadReckon[update.Guid] = drState;
}
else
{
float dtSec = (float)(now - drState.LastServerPosTime).TotalSeconds;
if (dtSec > 0.01f && dtSec < 1.0f)
{
// 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;
}
}
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
}
}
@ -3134,13 +3340,32 @@ public sealed class GameWindow : IDisposable
// 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.
const double StopIdleMs = 400.0;
//
// 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;
var now = System.DateTime.UtcNow;
foreach (var kv in _animatedEntities)
{
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.
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
@ -3153,24 +3378,162 @@ public sealed class GameWindow : IDisposable
|| motionLo == 0x07 // RunForward
|| motionLo == 0x0F // SideStepRight
|| motionLo == 0x10; // SideStepLeft
// Locate the server guid for this entity (reverse lookup).
// Skip the player's own entity — we drive our own anim locally.
uint serverGuid = 0;
foreach (var esg in _entitiesByServerGuid)
{
if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; }
}
if (inLocomotion
&& serverGuid != 0
&& serverGuid != _playerServerGuid
&& _remoteLastMove.TryGetValue(serverGuid, out var last)
&& (now - last.Time).TotalMilliseconds > StopIdleMs)
&& serverGuid != _playerServerGuid)
{
uint curStyle = ae.Sequencer.CurrentStyle;
uint ready = (curStyle & 0xFF000000u) != 0
? ((curStyle & 0xFF000000u) | 0x01000003u)
: 0x41000003u;
ae.Sequencer.SetCycle(curStyle, ready);
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
// samples. Each tick we advance entity.Position by the
// sequencer's current velocity (rotated into world space by the
// entity's facing) — matching the retail client's
// apply_current_movement (chunk_00520000.c L7132-L7189) and
// holtburger's project_pose_by_velocity.
//
// The cap on predict-distance from the last server pos prevents
// runaway when the sequencer's velocity and the server's reality
// disagree (e.g. server is rubber-banding the entity). Retail
// uses a similar clamp at PhysicsObj::IsInterpolationComplete.
if (ae.Sequencer is not null
&& serverGuid != 0
&& serverGuid != _playerServerGuid
&& _remoteDeadReckon.TryGetValue(serverGuid, out var drState))
{
System.Numerics.Vector3 worldVel = System.Numerics.Vector3.Zero;
// 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)
{
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;
}
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;
}
}
}
// 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);
}
}
}
}
@ -3343,9 +3706,16 @@ public sealed class GameWindow : IDisposable
else
animCommand = 0x41000003u; // Ready (idle)
// Fast path: no change.
if (animCommand == _playerCurrentAnimCommand) return;
// Fast path: no command change AND speed delta is negligible. If
// command is unchanged but speed changed, we must still propagate
// so the sequencer can MultiplyCyclicFramerate — keeping the run
// loop smooth without restart.
float newSpeed = result.ForwardSpeed ?? 1f;
bool sameCmd = animCommand == _playerCurrentAnimCommand;
bool sameSpeed = MathF.Abs(newSpeed - _playerCurrentAnimSpeed) < 1e-3f;
if (sameCmd && sameSpeed) return;
_playerCurrentAnimCommand = animCommand;
_playerCurrentAnimSpeed = newSpeed;
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return;
@ -3394,10 +3764,21 @@ public sealed class GameWindow : IDisposable
// Sequencer path: SetCycle handles adjust_motion internally
// (TurnLeft→TurnRight with negative speed, etc.)
//
// Speed scaling: use the MovementResult's ForwardSpeed for
// locomotion cycles. This mirrors what the server broadcasts for
// remote observers, and keeps our own character's animation rate
// in sync with movement velocity (a 1.5× RunRate player's anim
// plays 1.5× as fast — matching retail).
if (ae.Sequencer is not null)
{
uint fullStyle = 0x80000000u | (uint)NonCombatStance;
ae.Sequencer.SetCycle(fullStyle, animCommand);
float animSpeed = 1f;
if (result.ForwardSpeed is { } fs && fs > 0f)
{
animSpeed = fs;
}
ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed);
}
// Legacy path: update the manual slerp fields (for entities without sequencer)

View file

@ -1,4 +1,5 @@
using System.Buffers.Binary;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
@ -109,7 +110,27 @@ 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>
public readonly record struct ServerMotionState(ushort Stance, ushort? ForwardCommand, float? ForwardSpeed = null);
public readonly record struct ServerMotionState(
ushort Stance,
ushort? ForwardCommand,
float? ForwardSpeed = null,
IReadOnlyList<MotionItem>? Commands = null);
/// <summary>
/// One entry in the InterpretedMotionState's Commands list (MotionItem).
/// The server packs 0..many of these per broadcast: emotes, attacks,
/// and other one-shot motions arrive here, not in ForwardCommand.
///
/// Wire layout (see ACE Network/Motion/MotionItem.cs):
/// u16 command — low 16 bits of MotionCommand (Action class
/// typically 0x10xx; ChatEmote 0x13xx)
/// u16 packedSequence — bit 15 IsAutonomous, bits 0-14 sequence stamp
/// f32 speed — speedMod for the animation
/// </summary>
public readonly record struct MotionItem(
ushort Command,
ushort PackedSequence,
float Speed);
/// <summary>
/// Server instruction to replace the surface texture at
@ -480,6 +501,7 @@ public static class CreateObject
ushort? forwardCommand = null;
float? forwardSpeed = null;
List<MotionItem>? commands = null;
// 0 = Invalid is the only union variant we care about for static
// entities. Walking/turning entities use the other variants but
@ -488,21 +510,20 @@ public static class CreateObject
if (movementType == 0)
{
// InterpretedMotionState: u32 (flags | numCommands<<7), then
// each present field in flag order. We only care about
// ForwardCommand, so read in order and stop early if we
// can't get that far.
// each present field in flag order. Flag bits (low 7) are
// CurrentStyle/ForwardCommand/.../TurnSpeed; numCommands is
// the MotionItem list length that follows after the speed
// fields (see ACE InterpretedMotionState.cs::Write).
if (mv.Length - p < 4) return new ServerMotionState(currentStyle, null);
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(mv.Slice(p));
p += 4;
uint flags = packed & 0x7Fu; // MovementStateFlag bits live in low 7 bits
uint numCommands = packed >> 7;
// CurrentStyle (0x1)
if ((flags & 0x1u) != 0)
{
if (mv.Length - p < 2) return new ServerMotionState(currentStyle, null);
// The InterpretedMotionState's CurrentStyle is just a copy
// of MovementData.CurrentStyle per ACE source. Read and
// prefer it as the more specific value.
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
p += 2;
}
@ -525,10 +546,32 @@ public static class CreateObject
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; }
// Commands list: numCommands × 8-byte MotionItem (u16 cmd +
// u16 packedSeq + f32 speed). One-shot actions, emotes,
// attacks — everything that's NOT a looping cycle change
// arrives here. Cap read at the buffer boundary.
if (numCommands > 0 && numCommands < 1024)
{
commands = new List<MotionItem>((int)numCommands);
for (int i = 0; i < numCommands; i++)
{
if (mv.Length - p < 8) break;
ushort cmd = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p));
ushort seq = BinaryPrimitives.ReadUInt16LittleEndian(mv.Slice(p + 2));
float speed = BinaryPrimitives.ReadSingleLittleEndian(mv.Slice(p + 4));
p += 8;
commands.Add(new MotionItem(cmd, seq, speed));
}
}
done:;
}
return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed);
return new ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands);
}
catch
{

View file

@ -1,4 +1,5 @@
using System.Buffers.Binary;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
@ -122,17 +123,19 @@ public static class UpdateMotion
ushort? forwardCommand = null;
float? forwardSpeed = 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.
// Only ForwardCommand is pulled out; the rest is deliberately
// ignored because the animation system consumes nothing else.
// 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;
// CurrentStyle (0x1) — prefer the InterpretedMotionState's copy
// if present, matching the CreateObject parser's behavior.
@ -161,10 +164,30 @@ public static class UpdateMotion
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; }
// 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));
return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, forwardCommand, forwardSpeed, commands));
}
catch
{

View file

@ -121,6 +121,26 @@ internal sealed class AnimNode
Omega = omega;
}
// ── FUN_005267E0 — multiply_framerate ─────────────────────────────────
// Scales this node's framerate by a factor. Used by
// AnimationSequencer.MultiplyCyclicFramerate to retarget an already-queued
// cyclic animation at a new playback speed without restarting.
//
// Retail's implementation additionally swapped StartFrame↔EndFrame for a
// negative factor (so the forward-playback advance loop could traverse
// either direction), but acdream's AnimNode keeps StartFrame ≤ EndFrame
// as an invariant and encodes direction purely via Framerate's sign — the
// Advance loop then checks against StartFrame as the lower bound for
// negative delta. So here we only scale.
//
// Mirrors ACE AnimSequenceNode.multiply_framerate / Sequence.cs L277-L287
// modulo the swap difference. Valid because the callers we care about
// (ForwardSpeed updates from UpdateMotion) only ever pass positive factors.
public void MultiplyFramerate(double factor)
{
Framerate *= factor;
}
// ── FUN_00526880 — GetStartFramePosition ──────────────────────────────
// Returns the initial framePosition cursor for this node.
// speedScale >= 0 → (double)startFrame
@ -201,21 +221,34 @@ public sealed class AnimationSequencer
public uint CurrentMotion { get; private set; }
/// <summary>
/// World-space per-second velocity from the currently active
/// <see cref="MotionData"/> (Sequence.Velocity in retail). Zero when no
/// motion data carries a velocity. Scaled by <c>speedMod</c> at enqueue
/// time.
/// Speed multiplier currently applied to the cyclic tail. Starts at 1.0
/// and is updated by <see cref="SetCycle"/> when the same motion is
/// re-issued with a different speed (which triggers
/// <see cref="MultiplyCyclicFramerate"/> instead of a cycle restart).
/// </summary>
public Vector3 CurrentVelocity =>
_currNode?.Value.Velocity ?? Vector3.Zero;
public float CurrentSpeedMod { get; private set; } = 1f;
/// <summary>
/// Radians-per-second omega (axis-angle integration rate) from the
/// currently active <see cref="MotionData"/>. Scaled by <c>speedMod</c>
/// at enqueue time.
/// Sequence-wide velocity mirror of ACE's <c>Sequence.Velocity</c> field.
/// Updated each time a MotionData is appended or combined — reflects the
/// MOST RECENT MotionData's velocity × speedMod, matching
/// <c>Sequence.SetVelocity</c> semantics (ACE Sequence.cs L127-L130,
/// <c>MotionTable.add_motion</c> L358-L370).
///
/// <para>
/// Crucially this is **not** per-node: while a link animation plays, the
/// surfaced velocity is still the cycle's velocity (the cycle was added
/// last, so SetVelocity's latest call wins). Remote entity dead-reckoning
/// reads this to integrate position without gapping during stance
/// transitions.
/// </para>
/// </summary>
public Vector3 CurrentOmega =>
_currNode?.Value.Omega ?? Vector3.Zero;
public Vector3 CurrentVelocity { get; private set; }
/// <summary>
/// Sequence-wide omega, matching <see cref="CurrentVelocity"/>'s semantics.
/// </summary>
public Vector3 CurrentOmega { get; private set; }
// Diagnostics
public int QueueCount => _queue.Count;
@ -313,10 +346,26 @@ public sealed class AnimationSequencer
break;
}
// Fast-path: already playing this exact motion at the same speed.
// Fast-path: already playing this exact motion.
//
// Retail (ACE MotionTable.cs:132-139): when motion == current and
// sign(speedMod) matches, DON'T restart the cycle — just rescale the
// in-flight cyclic-tail's framerate via multiply_cyclic_animation_framerate.
// This keeps the run/walk loop smooth when a new UpdateMotion arrives
// with a different ForwardSpeed (e.g. when the server broadcasts a
// player's updated RunRate mid-step).
if (CurrentStyle == style && CurrentMotion == motion
&& _firstCyclic != null && _queue.Count > 0)
{
if (MathF.Abs(speedMod - CurrentSpeedMod) > 1e-4f
&& MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod)
&& MathF.Abs(CurrentSpeedMod) > 1e-6f)
{
MultiplyCyclicFramerate(speedMod / CurrentSpeedMod);
CurrentSpeedMod = speedMod;
}
return;
}
// Resolve transition link (currentSubstate → adjustedMotion).
MotionData? linkData = CurrentMotion != 0
@ -331,6 +380,11 @@ public sealed class AnimationSequencer
// been played yet (ACE behaviour: non-cyclic anims drain naturally).
ClearCyclicTail();
// Clear sequence-wide physics before the rebuild. Retail's
// GetObjectSequence calls sequence.clear_physics() before each
// add_motion chain (MotionTable.cs L100-L101, L152-L153).
ClearPhysics();
// Enqueue link frames (with adjusted speed for left→right remapping).
if (linkData is { Anims.Count: > 0 })
EnqueueMotionData(linkData, adjustedSpeed, isLooping: false);
@ -371,6 +425,45 @@ public sealed class AnimationSequencer
CurrentStyle = style;
CurrentMotion = motion;
CurrentSpeedMod = speedMod;
}
/// <summary>
/// Scale every cyclic node's framerate by <paramref name="factor"/>, mirroring
/// ACE's <c>Sequence.multiply_cyclic_animation_framerate</c>
/// (<c>references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs</c> L277-L287,
/// retail decompile <c>FUN_00525CE0</c>). Walks <c>_firstCyclic</c> through
/// the tail of the queue and calls <see cref="AnimNode.MultiplyFramerate"/>
/// on each. The non-cyclic head (link frames) is untouched — those drain
/// at their original framerate, which matches retail: the sequencer
/// "catches up" the transition before applying the new run speed.
///
/// <para>
/// Called from <see cref="SetCycle"/> when the same (style, motion) pair
/// is re-issued with a different speedMod — for instance, when a remote
/// player's ForwardSpeed changes mid-run. Does NOT restart the animation,
/// so footsteps keep planting where they are.
/// </para>
/// </summary>
/// <param name="factor">Framerate multiplier (newSpeed / oldSpeed).</param>
public void MultiplyCyclicFramerate(float factor)
{
if (_firstCyclic == null) return;
if (factor < 0f || float.IsNaN(factor) || float.IsInfinity(factor))
return;
for (var node = _firstCyclic; node != null; node = node.Next)
{
node.Value.MultiplyFramerate((double)factor);
}
// Sequence-wide velocity/omega scale too. Retail's flow is
// subtract_motion(oldSpeed) + combine_motion(newSpeed) in
// MotionTable.change_cycle_speed (MotionTable.cs L372-L379), which
// algebraically equals scaling by newSpeed/oldSpeed — exactly
// what the factor represents here.
CurrentVelocity *= factor;
CurrentOmega *= factor;
}
/// <summary>
@ -654,6 +747,9 @@ public sealed class AnimationSequencer
_rootMotionRot = Quaternion.Identity;
CurrentStyle = 0;
CurrentMotion = 0;
CurrentSpeedMod = 1f;
CurrentVelocity = Vector3.Zero;
CurrentOmega = Vector3.Zero;
}
// ── Private helpers ──────────────────────────────────────────────────────
@ -745,6 +841,17 @@ public sealed class AnimationSequencer
omega);
}
/// <summary>
/// Reset the sequence's Velocity + Omega (retail Sequence.clear_physics,
/// ACE Sequence.cs L256-L260). Called before a style-transition rebuild
/// in SetCycle so we don't inherit velocity from the previous cycle.
/// </summary>
private void ClearPhysics()
{
CurrentVelocity = Vector3.Zero;
CurrentOmega = Vector3.Zero;
}
/// <summary>
/// Append all AnimData entries from <paramref name="motionData"/> to the
/// queue. Each AnimData becomes one AnimNode. Velocity / Omega from the
@ -758,6 +865,23 @@ public sealed class AnimationSequencer
Vector3 omg = motionData.Flags.HasFlag(MotionDataFlags.HasOmega)
? motionData.Omega * speedMod : Vector3.Zero;
// Sequence-wide velocity/omega update, matching ACE's
// MotionTable.add_motion (MotionTable.cs L358-L370): SetVelocity
// REPLACES the previous sequence velocity. When SetCycle enqueues
// link then cycle, the final CurrentVelocity is the cycle's — which
// is what dead-reckoning needs to read from the first frame of the
// link transition (the cycle velocity is already "queued up" even
// while a zero-velocity link plays visually).
//
// Only replace if HasVelocity (else we'd zero out a running cycle
// when a transient HasVelocity=0 modifier enqueues). Matches
// retail's conditional behavior: MotionData without HasVelocity
// doesn't touch the sequence velocity.
if (motionData.Flags.HasFlag(MotionDataFlags.HasVelocity))
CurrentVelocity = vel;
if (motionData.Flags.HasFlag(MotionDataFlags.HasOmega))
CurrentOmega = omg;
for (int i = 0; i < motionData.Anims.Count; i++)
{
bool nodeCycling = isLooping && (i == motionData.Anims.Count - 1);

View file

@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
namespace AcDream.Core.Physics;
/// <summary>
/// Reconstructs the 32-bit retail <see cref="DRWMotionCommand"/> value from
/// a 16-bit wire value broadcast in <c>InterpretedMotionState.Commands[]</c>.
///
/// <para>
/// The server serializes MotionCommands as <c>u16</c> (ACE
/// <c>InterpretedMotionState.cs:139</c>), truncating the class byte (Style /
/// SubState / Modifier / Action / ChatEmote / UI / Toggle / Mappable /
/// Command — see r03 §3.1). The client must re-attach the class byte before
/// routing the command into the motion table, because the same low 16 bits
/// can map to different classes (e.g. 0x0003 is <c>Ready</c> as a SubState,
/// but there's no other 0x0003).
/// </para>
///
/// <para>
/// This is implemented as an eager lookup table built from all values of
/// <see cref="DRWMotionCommand"/> via reflection. If the wire value matches
/// more than one enum value (different class bits), we prefer the
/// lowest-class-numbered variant that has a non-zero class byte — roughly
/// matching retail priority (Action &lt; Modifier &lt; SubState &lt; Style).
/// </para>
///
/// <para>
/// Cited references:
/// <list type="bullet">
/// <item><description>
/// <c>references/ACE/Source/ACE.Server/Network/Motion/InterpretedMotionState.cs::Write</c>
/// L138-L144 — writer emits u16 for every command field.
/// </description></item>
/// <item><description>
/// <c>references/ACE/Source/ACE.Entity/Enum/CommandMasks.cs</c> — the
/// class bit assignments: 0x80=Style, 0x40=SubState, 0x20=Modifier,
/// 0x10=Action, 0x13 and 0x12=ChatEmote (with Mappable set), etc.
/// </description></item>
/// <item><description>
/// <c>docs/research/deepdives/r03-motion-animation.md</c> §3 — complete
/// command catalogue.
/// </description></item>
/// </list>
/// </para>
/// </summary>
public static class MotionCommandResolver
{
// Lookup table built eagerly at type-init. Sparse: only values that
// appear in the DRW enum (which came from the generated protocol XML)
// are present. ~450 entries typical.
private static readonly Dictionary<ushort, uint> s_lookup = BuildLookup();
/// <summary>
/// Given a 16-bit wire value, return the full 32-bit MotionCommand
/// (class byte restored). Returns 0 if no matching enum value exists.
/// </summary>
public static uint ReconstructFullCommand(ushort wireCommand)
{
if (wireCommand == 0) return 0u;
s_lookup.TryGetValue(wireCommand, out var full);
return full;
}
private static Dictionary<ushort, uint> BuildLookup()
{
var result = new Dictionary<ushort, uint>(512);
var values = Enum.GetValues(typeof(DRWMotionCommand));
foreach (DRWMotionCommand v in values)
{
uint full = (uint)v;
ushort lo = (ushort)(full & 0xFFFFu);
if (lo == 0) continue; // Invalid / unmappable
// If a value with this low-16-bit already exists, keep the one
// with the lower class byte (Action=0x10 beats SubState=0x41
// beats Style=0x80). This matches retail: the server tends to
// emit Actions and ChatEmotes far more often than Styles, so
// the Action-class reconstruction is the common case.
if (!result.TryGetValue(lo, out var existing)
|| (full >> 24) < (existing >> 24))
{
result[lo] = full;
}
}
return result;
}
}