using AcDream.App.UI; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; namespace AcDream.App.World; /// /// Bridges retail's pending split-to-3D identity into canonical live-entity /// registration when an ACE owner receives the result's Position before a /// CreateObject. /// /// /// Retail records splitClassID, splitStackSize, and /// splitTime in ACCWeenieObject::UIAttemptSplitTo3D @ /// 0x0058D850, then recognizes the created stack from /// ACCWeenieObject::DeclareValid @ 0x0058E340. ACE currently omits /// CreateObject for the initiating player but sends the new GUID's F748 /// Position. This owner accepts only that otherwise-impossible packet while /// the exact retail pending-split identity is live; arbitrary unknown Position /// packets remain rejected by the ordinary authority gate. /// internal sealed class InventoryWorldDropProjectionController : IDisposable { private readonly ItemInteractionController _interaction; private readonly ClientObjectTable _objects; private readonly LiveEntityRuntime _runtime; private readonly LiveEntityHydrationController _hydration; private readonly PendingSplitToWorldProjection _pending; private readonly Func _now; private bool _disposed; public InventoryWorldDropProjectionController( ItemInteractionController interaction, ClientObjectTable objects, LiveEntityRuntime runtime, LiveEntityHydrationController hydration, Func now) { _interaction = interaction ?? throw new ArgumentNullException(nameof(interaction)); _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _hydration = hydration ?? throw new ArgumentNullException(nameof(hydration)); _now = now ?? throw new ArgumentNullException(nameof(now)); _pending = new PendingSplitToWorldProjection(); _interaction.WorldDropDispatched += OnWorldDropDispatched; _objects.MoveRequestFailed += OnMoveRequestFailed; _objects.Cleared += OnObjectsCleared; } public bool TryRecoverUnknownPosition( WorldSession.EntityPositionUpdate update) { bool known = _runtime.TryGetSnapshot(update.Guid, out _); if (known || !_pending.TryResolve(update, _now(), out WorldSession.EntitySpawn spawn)) { return false; } // Consume before the synchronous CreateObject graph runs. Re-entrant // callbacks cannot bind the same split intent to a second GUID. _hydration.OnCreate(spawn); return _runtime.TryGetSnapshot(update.Guid, out _); } public void Dispose() { if (_disposed) return; _disposed = true; _objects.Cleared -= OnObjectsCleared; _objects.MoveRequestFailed -= OnMoveRequestFailed; _interaction.WorldDropDispatched -= OnWorldDropDispatched; _pending.Clear(); } private void OnWorldDropDispatched(WorldDropDispatch dispatch) { bool hasSource = _runtime.TryGetSnapshot( dispatch.Request.ItemId, out WorldSession.EntitySpawn source); if (dispatch.Request.Kind is not InventoryRequestKind.SplitToWorld || dispatch.Amount == 0 || !hasSource) { return; } _pending.Record( dispatch.Request.Token, source, dispatch.Amount, _now()); } private void OnMoveRequestFailed(MoveRequestFailure failure) => _pending.ClearSource(failure.ItemId); private void OnObjectsCleared() => _pending.Clear(); } /// /// Presentation-independent pending identity for retail split-to-3D /// recognition. Retail owns one static split identity and replaces it on the /// next request; this owner intentionally does the same. /// internal sealed class PendingSplitToWorldProjection { internal const double RetailRecognitionSeconds = 10.0; private PendingSplit? _value; internal bool HasPending => _value is not null; internal void Record( ulong requestToken, WorldSession.EntitySpawn source, uint amount, double now) { if (requestToken == 0 || source.Guid == 0 || amount == 0 || !double.IsFinite(now)) { _value = null; return; } _value = new PendingSplit(requestToken, source, amount, now); } internal bool TryResolve( WorldSession.EntityPositionUpdate update, double now, out WorldSession.EntitySpawn spawn) { spawn = default; if (_value is not { } pending) return false; double age = now - pending.RequestTime; if (!double.IsFinite(age) || age < 0 || age >= RetailRecognitionSeconds) { _value = null; return false; } if (update.Guid == 0 || update.Guid == pending.Source.Guid) return false; _value = null; spawn = BuildSpawn(pending.Source, pending.Amount, update); return true; } internal void ClearSource(uint sourceGuid) { if (_value is { } pending && pending.Source.Guid == sourceGuid) _value = null; } internal void Clear() => _value = null; private static WorldSession.EntitySpawn BuildSpawn( WorldSession.EntitySpawn source, uint amount, WorldSession.EntityPositionUpdate update) { PhysicsSpawnData? physics = source.Physics is { } sourcePhysics ? sourcePhysics with { Position = update.Position, Parent = null, Velocity = update.Velocity, // #314: every timestamp this record projects to the flattened // spawn fields below MUST be reset here in the same breath. // HasConsistentCreateIdentityAndParent // (RuntimeEntityObjectLifetime) rejects a create whose // PhysicsDesc timestamps disagree with their flattened // projections, and the `source with { … }` below zeroes // MovementSequence/ServerControlSequence. Omitting the // matching Movement/ServerControlledMove resets here left a // split whose SOURCE carried nonzero movement stamps — any // item dropped once, picked back up, and split again — // failing that predicate and throwing instead of completing // the canonical create-placement transaction. A fresh split // GUID has no movement history by construction, so zero is // the honest value, not a placation of the predicate. Timestamps = sourcePhysics.Timestamps with { Position = update.PositionSequence, Teleport = update.TeleportSequence, ForcePosition = update.ForcePositionSequence, Instance = update.InstanceSequence, Movement = 0, ServerControlledMove = 0, }, } : null; return source with { Guid = update.Guid, Position = update.Position, StackSize = checked((int)amount), ContainerId = 0u, WielderId = 0u, CurrentWieldedLocation = 0u, InstanceSequence = update.InstanceSequence, MovementSequence = 0, ServerControlSequence = 0, PositionSequence = update.PositionSequence, ParentGuid = null, ParentLocation = null, PlacementId = update.PlacementId, Physics = physics, }; } private readonly record struct PendingSplit( ulong RequestToken, WorldSession.EntitySpawn Source, uint Amount, double RequestTime); }