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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue