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:
commit
862cd5662f
8 changed files with 1273 additions and 46 deletions
|
|
@ -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 ~100–200 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
89
src/AcDream.Core/Physics/MotionCommandResolver.cs
Normal file
89
src/AcDream.Core/Physics/MotionCommandResolver.cs
Normal 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 < Modifier < SubState < 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue