From b96b680a2054a656301fefd5479c0163ee27b6fd Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Apr 2026 19:21:02 +0200 Subject: [PATCH] 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. --- src/AcDream.App/Rendering/GameWindow.cs | 123 +++++++++++++++--- src/AcDream.Core.Net/Messages/DeleteObject.cs | 39 ++++++ src/AcDream.Core.Net/WorldSession.cs | 16 +++ .../Messages/DeleteObjectTests.cs | 39 ++++++ .../Physics/AnimationSequencerTests.cs | 39 ++++++ 5 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 src/AcDream.Core.Net/Messages/DeleteObject.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs 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() {