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;
}
}

View file

@ -110,6 +110,74 @@ public class UpdateMotionTests
Assert.Null(result.Value.MotionState.ForwardCommand);
}
[Fact]
public void ParsesForwardSpeed_WhenSpeedFlagSet()
{
// Flags = CurrentStyle | ForwardCommand | ForwardSpeed (0x1|0x2|0x10 = 0x13)
// 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;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1A2B3C4Du); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6; // MovementData header
body[p++] = 0;
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x13u); 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
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance);
Assert.Equal((ushort)0x0007, result.Value.MotionState.ForwardCommand);
Assert.Equal(1.5f, result.Value.MotionState.ForwardSpeed);
}
[Fact]
public void ParsesCommandsList_Wave()
{
// A typical NPC wave broadcast:
// - stance NonCombat (0x003D)
// - ForwardCommand flag set, command = 0x0003 (Ready)
// - numCommands = 1, with a single MotionItem{ cmd=0x0087 Wave, seq=0, speed=1.0 }
//
// Packed u32 = (flags | numCommands << 7)
// flags = 0x01 (CurrentStyle) | 0x02 (ForwardCommand) = 0x03
// numCommands << 7 = 1 << 7 = 0x80
// total = 0x83
var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 2 + 8];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xDEADBEEFu); p += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
p += 6;
body[p++] = 0;
body[p++] = 0;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x83u); p += 4; // flags=0x3 + numCommands=1
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // stance
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0003); p += 2; // fwd cmd = Ready
// MotionItem: u16 command + u16 packedSeq + f32 speed
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0087); p += 2; // Wave
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0001); p += 2;
BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4;
var result = UpdateMotion.TryParse(body);
Assert.NotNull(result);
Assert.Equal((ushort)0x003D, result!.Value.MotionState.Stance);
Assert.Equal((ushort)0x0003, result.Value.MotionState.ForwardCommand);
Assert.NotNull(result.Value.MotionState.Commands);
Assert.Single(result.Value.MotionState.Commands!);
var wave = result.Value.MotionState.Commands![0];
Assert.Equal((ushort)0x0087, wave.Command);
Assert.Equal(1.0f, wave.Speed);
}
[Fact]
public void HandlesNonInvalidMovementType_GracefullyReturnsOuterStance()
{

View file

@ -979,6 +979,452 @@ public sealed class AnimationSequencerTests
Assert.Contains(hooks, h => h is AnimationDoneHook);
}
// ── MultiplyCyclicFramerate / speed-mod tracking ─────────────────────────
[Fact]
public void MultiplyCyclicFramerate_DoublesPlaybackRate()
{
// A 10-frame cycle at 10 fps = 1.0s per loop. If we halve the playback
// rate (factor 0.5), advancing 1.0s should produce half a loop (5 frames).
const uint Style = 0x003Du;
const uint Motion = 0x0007u; // RunForward
const uint AnimId = 0x03000401u;
// Unique per-frame Z so we can tell where the cursor lands.
var anim = new Animation();
for (int f = 0; f < 10; f++)
{
var pf = new AnimationFrame(1);
pf.Frames.Add(new Frame { Origin = new Vector3(0, 0, f), Orientation = Quaternion.Identity });
anim.PartFrames.Add(pf);
}
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) };
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData
{
AnimId = qid,
LowFrame = 0,
HighFrame = 9,
Framerate = 10f,
});
mt.Cycles[cycleKey] = md;
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 1f);
// Halve the playback rate.
seq.MultiplyCyclicFramerate(0.5f);
// 10 frames at 5 fps = 2.0s per loop. Advance 1.0s → cursor ~= frame 5.
seq.Advance(1.0f);
var frames = seq.Advance(0.001f);
Assert.Single(frames);
Assert.InRange(frames[0].Origin.Z, 4f, 6f);
// Velocity also scales: originally (0,4,0), now (0,2,0).
Assert.Equal(2f, seq.CurrentVelocity.Y, 1);
}
[Fact]
public void MultiplyCyclicFramerate_PreservesCursorPosition()
{
// Changing speed mid-cycle must NOT reset the frame cursor — the
// animation keeps playing from where it was, just faster/slower.
const uint Style = 0x003Du;
const uint Motion = 0x0007u;
const uint AnimId = 0x03000402u;
var anim = new Animation();
for (int f = 0; f < 10; f++)
{
var pf = new AnimationFrame(1);
pf.Frames.Add(new Frame { Origin = new Vector3(0, 0, f), Orientation = Quaternion.Identity });
anim.PartFrames.Add(pf);
}
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(AnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion);
seq.Advance(0.3f); // cursor ~ frame 3
double before = GetFramePosition(seq);
seq.MultiplyCyclicFramerate(2.0f);
double after = GetFramePosition(seq);
Assert.Equal(before, after, 5);
}
[Fact]
public void SetCycle_SameMotionDifferentSpeed_RescalesInPlace()
{
// Re-issuing SetCycle with the same motion but a new speedMod must
// NOT reset the cursor — it should call MultiplyCyclicFramerate to
// keep the run loop smooth (retail behavior for a mid-run RunRate
// broadcast). Mirror of ACE MotionTable.cs:132-139 fast-path.
const uint Style = 0x003Du;
const uint Motion = 0x0007u;
const uint AnimId = 0x03000403u;
var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 1f);
seq.Advance(0.3f);
double cursorMid = GetFramePosition(seq);
Assert.Equal(1f, seq.CurrentSpeedMod, 3);
// Re-issue with 2× speed — should rescale in place.
seq.SetCycle(Style, Motion, speedMod: 2f);
Assert.Equal(2f, seq.CurrentSpeedMod, 3);
Assert.Equal(cursorMid, GetFramePosition(seq), 5);
}
[Fact]
public void CurrentVelocity_ScalesWithSpeedMod()
{
// A RunForward motion with MotionData.Velocity = (0,4,0) should
// surface as (0,4,0) at speedMod=1.0, (0,6,0) at 1.5×, (0,2,0) at
// 0.5×. The dead-reckoning integrator in TickAnimations reads
// CurrentVelocity each tick, so this has to be accurate.
const uint Style = 0x003Du;
const uint Motion = 0x0007u;
const uint AnimId = 0x03000405u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) };
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData
{
AnimId = qid,
LowFrame = 0,
HighFrame = -1,
Framerate = 10f,
});
mt.Cycles[cycleKey] = md;
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 1f);
Assert.Equal(4f, seq.CurrentVelocity.Y, 3);
// Start a fresh sequencer so the initial SetCycle applies speedMod.
var seq2 = new AnimationSequencer(setup, mt, loader);
seq2.SetCycle(Style, Motion, speedMod: 1.5f);
Assert.Equal(6f, seq2.CurrentVelocity.Y, 3);
// Same-motion rescale path also updates velocity.
seq2.SetCycle(Style, Motion, speedMod: 0.5f);
Assert.Equal(2f, seq2.CurrentVelocity.Y, 2);
}
[Fact]
public void SetCycle_SameMotionSameSpeed_StaysNoOp()
{
// Guard: the new speed-path must not break the classic
// "identical call = no state change" behavior.
const uint Style = 0x003Du;
const uint Motion = 0x0007u;
const uint AnimId = 0x03000404u;
var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 1.5f);
seq.Advance(0.2f);
double before = GetFramePosition(seq);
seq.SetCycle(Style, Motion, speedMod: 1.5f);
Assert.Equal(before, GetFramePosition(seq), 5);
Assert.Equal(1.5f, seq.CurrentSpeedMod, 3);
}
[Fact]
public void CurrentOmega_ReflectsMotionDataOmega()
{
// A turn cycle with MotionData.Omega = (0, 0, 1) rad/sec (yaw)
// should surface as CurrentOmega = (0, 0, 1) after SetCycle.
// Scales with speedMod exactly like Velocity.
const uint Style = 0x003Du;
const uint Motion = 0x000Du; // TurnRight
const uint AnimId = 0x03000701u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
var md = new MotionData { Flags = MotionDataFlags.HasOmega, Omega = new Vector3(0, 0, 1.0f) };
QualifiedDataId<Animation> qid = AnimId;
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 10f });
mt.Cycles[cycleKey] = md;
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 2f);
// Omega scales by speedMod — 1.0 × 2 = 2 rad/sec.
Assert.Equal(2.0f, seq.CurrentOmega.Z, 3);
}
[Fact]
public void CurrentVelocity_PersistsThroughLinkTransition()
{
// Retail behavior (ACE MotionTable.add_motion + Sequence.SetVelocity):
// sequence.Velocity is REPLACED by the most-recent MotionData's
// velocity. When SetCycle enqueues [link][cycle], after the final
// add_motion the velocity is the cycle's velocity — ALREADY.
// So even while the link animation plays visually, dead-reckoning
// reads the cycle's run-speed and moves the entity smoothly.
// Crucial: otherwise remote entities would stutter at every stance
// transition while the link plays.
const uint Style = 0x003Du;
const uint IdleMotion = 0x0003u;
const uint WalkMotion = 0x0005u;
const uint CycleAnim = 0x03000601u;
const uint LinkAnim = 0x03000602u;
var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)WalkMotion;
int cycleKey = (int)((Style << 16) | (WalkMotion & 0xFFFFFFu));
var cycleMd = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 3.12f, 0) };
QualifiedDataId<Animation> cycleQid = CycleAnim;
cycleMd.Anims.Add(new AnimData { AnimId = cycleQid, LowFrame = 0, HighFrame = -1, Framerate = 10f });
mt.Cycles[cycleKey] = cycleMd;
// Link from idle → walk. Link MotionData has no velocity (typical).
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
var linkCmdData = new MotionCommandData();
var linkMd = new MotionData(); // no HasVelocity flag
QualifiedDataId<Animation> linkQid = LinkAnim;
linkMd.Anims.Add(new AnimData { AnimId = linkQid, LowFrame = 0, HighFrame = -1, Framerate = 10f });
linkCmdData.MotionData[(int)WalkMotion] = linkMd;
mt.Links[linkOuter] = linkCmdData;
var loader = new FakeLoader();
loader.Register(CycleAnim, cycleAnim);
loader.Register(LinkAnim, linkAnim);
var seq = new AnimationSequencer(setup, mt, loader);
SetCurrentMotion(seq, Style, IdleMotion);
seq.SetCycle(Style, WalkMotion);
// We just enqueued [link(0)][cycle(3.12 forward)]. Current node is
// the link, but CurrentVelocity reflects the most recent
// SetVelocity call — the cycle's. So velocity is 3.12 even before
// the link plays out.
Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2);
// Advance past the link frames (2 frames at 10fps = 0.2s).
seq.Advance(0.25f);
// Still 3.12 — cycle is now current.
Assert.Equal(3.12f, seq.CurrentVelocity.Y, 2);
}
// ── PlayAction: Action / Modifier / ChatEmote routing ───────────────────
[Fact]
public void PlayAction_Action_ResolvesFromLinksDict()
{
// An Action-class command (mask 0x10) resolves via the Links dict
// keyed by (style, currentSubstate) → motion. Example: a ThrustMed
// attack while in SwordCombat stance.
const uint Style = 0x003Eu; // SwordCombat
const uint IdleMotion = 0x41000003u; // Ready
const uint ActionMotion = 0x10000058u; // ThrustMed (Action class)
const uint IdleAnimId = 0x03000501u;
const uint ActionAnimId= 0x03000502u;
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
// Action anim: distinct non-zero origin so we can detect it played.
var actionAnim = Fixtures.MakeAnim(3, 1, new Vector3(99, 0, 0), Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
// Link: (SwordCombat, Ready) → ThrustMed
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
var cmdData = new MotionCommandData();
cmdData.MotionData[(int)ActionMotion] = Fixtures.MakeMotionData(ActionAnimId, framerate: 10f);
mt.Links[linkOuter] = cmdData;
var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim);
loader.Register(ActionAnimId, actionAnim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.Advance(0.01f); // burn the first idle frame
// Fire the action.
seq.PlayAction(ActionMotion);
// After a small advance, we should be reading the action anim (origin X=99).
var fr = seq.Advance(0.01f);
Assert.Single(fr);
Assert.Equal(99f, fr[0].Origin.X, 1);
}
[Fact]
public void PlayAction_Modifier_ResolvesFromModifiersDict()
{
// A Modifier-class command (mask 0x20) — like Jump (0x2500003B) —
// resolves from the Modifiers dict, first with style-specific key
// then with unstyled fallback. Empirically: the modifier's anim
// plays on top of the current cycle.
const uint Style = 0x003Du;
const uint IdleMotion = 0x41000003u;
const uint JumpMotion = 0x2500003Bu; // Modifier class
const uint IdleAnimId = 0x03000510u;
const uint JumpAnimId = 0x03000511u;
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var jumpAnim = Fixtures.MakeAnim(3, 1, new Vector3(0, 0, 77), Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
// Modifier: (Style, Jump)
int modKey = (int)((Style << 16) | (JumpMotion & 0xFFFFFFu));
mt.Modifiers[modKey] = Fixtures.MakeMotionData(JumpAnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim);
loader.Register(JumpAnimId, jumpAnim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.PlayAction(JumpMotion);
var fr = seq.Advance(0.01f);
Assert.Single(fr);
Assert.Equal(77f, fr[0].Origin.Z, 1);
}
[Fact]
public void PlayAction_Emote_RoutesThroughActionBranch()
{
// ChatEmotes like Wave (0x13000087) have class byte 0x13 =
// Action(0x10) | ChatEmote(0x02) | Mappable(0x01). Because the
// Action bit is set, they route through the Links-dict lookup just
// like attacks. Verifies the class-bit math.
const uint Style = 0x003Du;
const uint IdleMotion = 0x41000003u;
const uint WaveMotion = 0x13000087u;
const uint IdleAnimId = 0x03000520u;
const uint WaveAnimId = 0x03000521u;
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var waveAnim = Fixtures.MakeAnim(5, 1, new Vector3(0, 55, 0), Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
// Register Links[(style, Ready)][Wave] = wave anim.
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
var cmdData = new MotionCommandData();
cmdData.MotionData[(int)WaveMotion] = Fixtures.MakeMotionData(WaveAnimId, framerate: 10f);
mt.Links[linkOuter] = cmdData;
var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim);
loader.Register(WaveAnimId, waveAnim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.PlayAction(WaveMotion);
var fr = seq.Advance(0.01f);
Assert.Single(fr);
Assert.Equal(55f, fr[0].Origin.Y, 1);
}
[Fact]
public void PlayAction_NoEntryInTable_IsNoOp()
{
// If neither Links nor Modifiers has the motion, PlayAction should
// silently return without disturbing the current cycle.
const uint Style = 0x003Du;
const uint IdleMotion = 0x41000003u;
const uint IdleAnimId = 0x03000530u;
const uint UnknownAction = 0x10001234u;
var idleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable();
mt.DefaultStyle = (DRWMotionCommand)Style;
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(IdleAnimId, idleAnim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.Advance(0.05f);
int queueBefore = seq.QueueCount;
seq.PlayAction(UnknownAction); // unknown motion → no-op
Assert.Equal(queueBefore, seq.QueueCount);
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// <summary>Expose _framePosition (double) via reflection (test-only).</summary>

View file

@ -0,0 +1,53 @@
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Validates MotionCommandResolver — reconstructs the class byte (0x10, 0x13,
/// 0x41, 0x80, etc) from a 16-bit wire value. Without this, the sequencer
/// routes commands to the wrong MotionTable dict and NPC emotes/attacks
/// silently fail.
/// </summary>
public class MotionCommandResolverTests
{
[Theory]
// SubState / Ready / Movement commands
[InlineData(0x0003, 0x41000003u)] // Ready
[InlineData(0x0005, 0x45000005u)] // WalkForward
[InlineData(0x0007, 0x44000007u)] // RunForward
[InlineData(0x0006, 0x45000006u)] // WalkBackward
[InlineData(0x000D, 0x6500000Du)] // TurnRight
[InlineData(0x000E, 0x6500000Eu)] // TurnLeft
[InlineData(0x000F, 0x6500000Fu)] // SideStepRight
[InlineData(0x0015, 0x40000015u)] // Falling
// Action-class one-shots: melee attacks, death, portals
[InlineData(0x0057, 0x10000057u)] // Sanctuary (death)
[InlineData(0x0058, 0x10000058u)] // ThrustMed
[InlineData(0x005B, 0x1000005Bu)] // SlashHigh
[InlineData(0x0061, 0x10000061u)] // Shoot
[InlineData(0x004B, 0x1000004Bu)] // Jumpup
[InlineData(0x0050, 0x10000050u)] // FallDown
// ChatEmotes (class 0x13)
[InlineData(0x0087, 0x13000087u)] // Wave
[InlineData(0x0080, 0x13000080u)] // Laugh
[InlineData(0x007D, 0x1300007Du)] // BowDeep
public void ReconstructsKnownCommands(ushort wire, uint expected)
{
uint got = MotionCommandResolver.ReconstructFullCommand(wire);
Assert.Equal(expected, got);
}
[Fact]
public void ZeroWireReturnsZero()
{
Assert.Equal(0u, MotionCommandResolver.ReconstructFullCommand(0));
}
[Fact]
public void UnknownWireReturnsZero()
{
// 0xFFFF is not a real MotionCommand low-16.
Assert.Equal(0u, MotionCommandResolver.ReconstructFullCommand(0xFFFF));
}
}