diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 1f03458..fe550c7 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -216,6 +216,14 @@ public sealed class GameWindow : IDisposable
/// Last known server position — kept for diagnostics / HUD.
public System.Numerics.Vector3 LastServerPos;
///
+ /// 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.
+ ///
+ public System.Numerics.Vector3 ServerVelocity;
+ public bool HasServerVelocity;
+ ///
/// Legacy field — no longer used for slerp (retail hard-snaps
/// per FUN_00514b90 set_frame). Kept to avoid churn.
///
@@ -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;
+ }
+
///
/// 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
{
diff --git a/src/AcDream.Core.Net/Messages/DeleteObject.cs b/src/AcDream.Core.Net/Messages/DeleteObject.cs
new file mode 100644
index 0000000..c18bb13
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/DeleteObject.cs
@@ -0,0 +1,39 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Inbound ObjectDelete GameMessage (opcode 0xF747).
+///
+///
+/// Retail dispatch path:
+/// CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 reads guid from
+/// buf+4 and instance sequence from buf+8, then calls
+/// SmartBox::HandleDeleteObject 0x00451EA0. ACE emits the same
+/// layout from GameMessageDeleteObject.
+///
+///
+public static class DeleteObject
+{
+ public const uint Opcode = 0xF747u;
+
+ public readonly record struct Parsed(uint Guid, ushort InstanceSequence);
+
+ ///
+ /// Parse a 0xF747 body. must start with the
+ /// 4-byte opcode, matching every other parser in this namespace.
+ ///
+ public static Parsed? TryParse(ReadOnlySpan 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);
+ }
+}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 580b1b9..885ec63 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -61,6 +61,16 @@ public sealed class WorldSession : IDisposable
/// Fires when the session finishes parsing a CreateObject.
public event Action? EntitySpawned;
+ ///
+ /// Fires when the session parses a 0xF747 ObjectDelete game message.
+ /// Retail routes this through
+ /// CM_Physics::DispatchSB_DeleteObject 0x006AC6A0 →
+ /// SmartBox::HandleDeleteObject 0x00451EA0; ACE emits it when
+ /// an object leaves the world, including the living creature object
+ /// after its corpse is created.
+ ///
+ public event Action? EntityDeleted;
+
///
/// Payload for : 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
diff --git a/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs
new file mode 100644
index 0000000..b464cab
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs
@@ -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 body = stackalloc byte[12];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
+
+ Assert.Null(DeleteObject.TryParse(body));
+ }
+
+ [Fact]
+ public void RejectsTruncated()
+ {
+ Assert.Null(DeleteObject.TryParse(ReadOnlySpan.Empty));
+ Assert.Null(DeleteObject.TryParse(new byte[9]));
+ }
+
+ [Fact]
+ public void ParsesGuidAndInstanceSequence()
+ {
+ Span 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);
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
index ac492dd..471af2c 100644
--- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
@@ -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()
{