fix(anim): Phase L.1c route creature actions and despawns

Handle retail ObjectDelete (0xF747) using CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 / SmartBox::HandleDeleteObject 0x00451EA0 and ACE GameMessageDeleteObject so dead creatures are removed when corpses spawn.

Route action-class ForwardCommand values through AnimationCommandRouter/PlayAction instead of SetCycle so creature attack commands 0x51/0x52/0x53 survive the immediate Ready echo, matching CMotionTable::GetObjectSequence 0x00522860 / ACE MotionTable.GetObjectSequence.

Use server-authoritative UpdatePosition velocity, or observed server position delta for non-player entities when HasVelocity is absent, to reduce monster/NPC chase lag without applying player RUM prediction to server-controlled creatures.
This commit is contained in:
Erik 2026-04-28 19:21:02 +02:00
parent 4874d8595a
commit b96b680a20
5 changed files with 235 additions and 21 deletions

View file

@ -216,6 +216,14 @@ public sealed class GameWindow : IDisposable
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
public System.Numerics.Vector3 LastServerPos;
/// <summary>
/// Latest server-authoritative velocity for NPC/monster smoothing.
/// Prefer the HasVelocity vector from UpdatePosition; when ACE omits
/// it for a server-controlled creature, derive it from consecutive
/// authoritative positions instead of guessing from player RUM state.
/// </summary>
public System.Numerics.Vector3 ServerVelocity;
public bool HasServerVelocity;
/// <summary>
/// Legacy field — no longer used for slerp (retail hard-snaps
/// per FUN_00514b90 set_frame). Kept to avoid churn.
/// </summary>
@ -527,6 +535,7 @@ public sealed class GameWindow : IDisposable
private readonly record struct LiveEntityInfo(
string? Name,
AcDream.Core.Items.ItemType ItemType);
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
private int _liveSpawnReceived; // diagnostics
private int _liveSpawnHydrated;
private int _liveDropReasonNoPos;
@ -1303,6 +1312,7 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"live: connecting to {endpoint} as {user}");
_liveSession = new AcDream.Core.Net.WorldSession(endpoint);
_liveSession.EntitySpawned += OnLiveEntitySpawned;
_liveSession.EntityDeleted += OnLiveEntityDeleted;
_liveSession.MotionUpdated += OnLiveMotionUpdated;
_liveSession.PositionUpdated += OnLivePositionUpdated;
_liveSession.VectorUpdated += OnLiveVectorUpdated;
@ -1654,23 +1664,7 @@ public sealed class GameWindow : IDisposable
// For a respawn, drop the previous rendering state here before we
// build the new one. `_entitiesByServerGuid` is the canonical map,
// its value is the live WorldEntity we need to dispose.
if (_entitiesByServerGuid.TryGetValue(spawn.Guid, out var existingEntity))
{
_worldState.RemoveEntityByServerGuid(spawn.Guid);
_worldGameState.RemoveById(existingEntity.Id);
_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);
_liveEntityInfoByGuid.Remove(spawn.Guid);
if (_selectedTargetGuid == spawn.Guid)
_selectedTargetGuid = null;
}
RemoveLiveEntityByServerGuid(spawn.Guid, logDelete: false);
// Log every spawn that arrives so we can inventory what the server
// sends (including the ones we can't render yet). The Name field
@ -2150,6 +2144,41 @@ public sealed class GameWindow : IDisposable
}
}
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
{
if (RemoveLiveEntityByServerGuid(delete.Guid, logDelete: true)
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
{
Console.WriteLine(
$"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}");
}
}
private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete)
{
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
return false;
_worldState.RemoveEntityByServerGuid(serverGuid);
_worldGameState.RemoveById(existingEntity.Id);
_animatedEntities.Remove(existingEntity.Id);
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
// Dead-reckon state is keyed by SERVER guid (not local id) so we
// clear using the same guid the next spawn/update would use.
_remoteDeadReckon.Remove(serverGuid);
_remoteLastMove.Remove(serverGuid);
_liveEntityInfoByGuid.Remove(serverGuid);
_entitiesByServerGuid.Remove(serverGuid);
if (_selectedTargetGuid == serverGuid)
_selectedTargetGuid = null;
if (logDelete)
_lightingSink?.UnregisterOwner(existingEntity.Id);
return true;
}
/// <summary>
/// Phase 6.6: the server says an entity's motion has changed. Look up
/// the AnimatedEntity for that guid, re-resolve the idle cycle with the
@ -2293,6 +2322,32 @@ public sealed class GameWindow : IDisposable
}
else
{
var forwardRoute = AcDream.Core.Physics.AnimationCommandRouter.Classify(fullMotion);
bool forwardIsOverlay = forwardRoute is AcDream.Core.Physics.AnimationCommandRouteKind.Action
or AcDream.Core.Physics.AnimationCommandRouteKind.Modifier
or AcDream.Core.Physics.AnimationCommandRouteKind.ChatEmote;
bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck)
&& rmCheck.Airborne;
// Retail MotionTable::GetObjectSequence routes action-class
// ForwardCommand values (creature attacks, chat-emotes) through
// the Action branch, where the swing is appended before the
// current cyclic tail and currState.Substate remains Ready.
// Treating 0x10000051/52/53 as SetCycle commands made the
// immediate follow-up Ready packet abort the swing.
if (forwardIsOverlay)
{
if (!remoteIsAirborne)
{
AcDream.Core.Physics.AnimationCommandRouter.RouteFullCommand(
ae.Sequencer,
fullStyle,
fullMotion,
speedMod <= 0f ? 1f : speedMod);
}
}
else
{
// Pick which cycle to play on the sequencer. Priority:
// 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk.
// 2. Else sidestep cmd if active — legs strafe.
@ -2340,8 +2395,6 @@ public sealed class GameWindow : IDisposable
// the post-resolve landing path restores the cycle to
// whatever the interpreted state says when the body
// lands.
bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck)
&& rmCheck.Airborne;
if (!remoteIsAirborne)
ae.Sequencer.SetCycle(fullStyle, animCycle, animSpeed);
@ -2419,6 +2472,7 @@ public sealed class GameWindow : IDisposable
}
}
}
}
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
// stamp the _remoteLastMove timestamp to "now". Without this,
@ -2634,6 +2688,26 @@ public sealed class GameWindow : IDisposable
// slerp doesn't visibly rotate from Identity to truth.
rmState.Body.Orientation = rot;
}
double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds;
System.Numerics.Vector3? serverVelocity = update.Velocity;
if (serverVelocity is null
&& !IsPlayerGuid(update.Guid)
&& rmState.LastServerPosTime > 0.0)
{
double elapsed = nowSec - rmState.LastServerPosTime;
if (elapsed > 0.001)
serverVelocity = (worldPos - rmState.LastServerPos) / (float)elapsed;
}
if (serverVelocity is { } authoritativeVelocity)
{
rmState.ServerVelocity = authoritativeVelocity;
rmState.HasServerVelocity = true;
}
else if (!IsPlayerGuid(update.Guid))
{
rmState.ServerVelocity = System.Numerics.Vector3.Zero;
rmState.HasServerVelocity = false;
}
rmState.Body.Position = worldPos;
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
@ -2673,7 +2747,7 @@ public sealed class GameWindow : IDisposable
rmState.Body.Orientation = rot;
rmState.TargetOrientation = rot;
rmState.LastServerPos = worldPos;
rmState.LastServerPosTime = (now - System.DateTime.UnixEpoch).TotalSeconds;
rmState.LastServerPosTime = nowSec;
// Align the body's physics clock with our clock so update_object
// doesn't sub-step a huge initial gap.
rmState.Body.LastUpdateTime = rmState.LastServerPosTime;
@ -2710,6 +2784,10 @@ public sealed class GameWindow : IDisposable
}
}
}
else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity)
{
rmState.Body.Velocity = rmState.ServerVelocity;
}
entity.Position = rmState.Body.Position;
entity.Rotation = rmState.Body.Orientation;
@ -4858,7 +4936,10 @@ public sealed class GameWindow : IDisposable
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active;
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity)
rm.Body.Velocity = rm.ServerVelocity;
else
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
else
{