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
{

View file

@ -0,0 +1,39 @@
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound <c>ObjectDelete</c> GameMessage (opcode <c>0xF747</c>).
///
/// <para>
/// Retail dispatch path:
/// <c>CM_Physics::DispatchSB_DeleteObject</c> 0x006AC6A0 reads guid from
/// <c>buf+4</c> and instance sequence from <c>buf+8</c>, then calls
/// <c>SmartBox::HandleDeleteObject</c> 0x00451EA0. ACE emits the same
/// layout from <c>GameMessageDeleteObject</c>.
/// </para>
/// </summary>
public static class DeleteObject
{
public const uint Opcode = 0xF747u;
public readonly record struct Parsed(uint Guid, ushort InstanceSequence);
/// <summary>
/// Parse a 0xF747 body. <paramref name="body"/> must start with the
/// 4-byte opcode, matching every other parser in this namespace.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
if (body.Length < 10)
return null;
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(0, 4));
if (opcode != Opcode)
return null;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2));
return new Parsed(guid, instanceSequence);
}
}

View file

@ -61,6 +61,16 @@ public sealed class WorldSession : IDisposable
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;
/// <summary>
/// Fires when the session parses a 0xF747 ObjectDelete game message.
/// Retail routes this through
/// <c>CM_Physics::DispatchSB_DeleteObject</c> 0x006AC6A0 →
/// <c>SmartBox::HandleDeleteObject</c> 0x00451EA0; ACE emits it when
/// an object leaves the world, including the living creature object
/// after its corpse is created.
/// </summary>
public event Action<DeleteObject.Parsed>? EntityDeleted;
/// <summary>
/// Payload for <see cref="MotionUpdated"/>: the server guid of the entity
/// whose motion changed and its new server-side stance + forward command.
@ -641,6 +651,12 @@ public sealed class WorldSession : IDisposable
parsed.Value.MotionTableId));
}
}
else if (op == DeleteObject.Opcode)
{
var parsed = DeleteObject.TryParse(body);
if (parsed is not null)
EntityDeleted?.Invoke(parsed.Value);
}
else if (op == UpdateMotion.Opcode)
{
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an

View file

@ -0,0 +1,39 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class DeleteObjectTests
{
[Fact]
public void RejectsWrongOpcode()
{
Span<byte> body = stackalloc byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
Assert.Null(DeleteObject.TryParse(body));
}
[Fact]
public void RejectsTruncated()
{
Assert.Null(DeleteObject.TryParse(ReadOnlySpan<byte>.Empty));
Assert.Null(DeleteObject.TryParse(new byte[9]));
}
[Fact]
public void ParsesGuidAndInstanceSequence()
{
Span<byte> body = stackalloc byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, DeleteObject.Opcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000439u);
BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x1234);
var parsed = DeleteObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(0x80000439u, parsed!.Value.Guid);
Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence);
}
}

View file

@ -1313,6 +1313,45 @@ public sealed class AnimationSequencerTests
Assert.Equal(99f, fr[0].Origin.X, 1);
}
[Fact]
public void PlayAction_ActionSurvivesImmediateReadyCycleEcho()
{
// ACE broadcasts creature attacks as Action-class ForwardCommand
// values followed by Ready. Retail keeps currState.Substate at Ready
// while the action link drains, so the Ready echo must not abort the
// in-flight swing.
const uint Style = 0x003Du;
const uint IdleMotion = 0x41000003u;
const uint AttackMotion = 0x10000052u;
const uint IdleAnimId = 0x03000503u;
const uint AttackAnimId = 0x03000504u;
var setup = Fixtures.MakeSetup(1);
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)Style };
int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f);
int linkOuter = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu));
var cmdData = new MotionCommandData();
cmdData.MotionData[(int)AttackMotion] = Fixtures.MakeMotionData(AttackAnimId, framerate: 10f);
mt.Links[linkOuter] = cmdData;
var loader = new FakeLoader();
loader.Register(IdleAnimId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity));
loader.Register(AttackAnimId, Fixtures.MakeAnim(3, 1, new Vector3(12, 0, 0), Quaternion.Identity));
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, IdleMotion);
seq.PlayAction(AttackMotion);
seq.SetCycle(Style, IdleMotion);
var fr = seq.Advance(0.01f);
Assert.Single(fr);
Assert.Equal(12f, fr[0].Origin.X, 1);
Assert.Equal(IdleMotion, seq.CurrentMotion);
}
[Fact]
public void PlayAction_Modifier_ResolvesFromModifiersDict()
{