fix(interaction): restore retail loot placement and world-drop projection

This commit is contained in:
Erik 2026-07-27 00:03:15 +02:00
parent 4d095be286
commit 921712f412
24 changed files with 1581 additions and 34 deletions

View file

@ -503,6 +503,17 @@ internal sealed class SessionPlayerCompositionPhase
live.LandblockLoaded.Bind(hydration));
Fault(SessionPlayerCompositionPoint.HydrationCreated);
var worldDropProjection =
new InventoryWorldDropProjectionController(
interaction.ItemInteraction,
d.EntityObjects.Objects,
live.LiveEntities,
hydration,
() => d.UpdateClock.SimulationTimeSeconds);
bindings.Adopt(
"inventory world-drop projection",
worldDropProjection);
var networkUpdates = new LiveEntityNetworkUpdateController(
live.LiveEntities,
d.EntityObjects.Objects,
@ -531,7 +542,8 @@ internal sealed class SessionPlayerCompositionPhase
d.UpdateClock,
liveSessionSource,
localPhysicsTimestamps.Publish,
d.MovementDiagnostics);
d.MovementDiagnostics,
worldDropProjection);
var liveness = new LiveEntityLivenessController(
live.LiveEntities,
d.PlayerIdentity,

View file

@ -230,12 +230,6 @@ internal sealed class SelectionInteractionController
CancelPickupPresentation(itemGuid);
return;
}
if (!ValidatePickupTarget(itemGuid, showToast: true)
|| !_query.TryGetApproach(itemGuid, out InteractionApproach approach))
{
CancelPickupPresentation(itemGuid);
return;
}
ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement(
itemGuid,
@ -251,6 +245,42 @@ internal sealed class SelectionInteractionController
return;
}
// Retail CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 delegates
// current-ground-object contents straight to
// ItemHolder::AttemptToPlaceInContainer @ 0x00588140. A corpse or
// chest child has no independent 3-D projection to approach.
if (_items.IsInCurrentGroundObject(itemGuid))
{
var contained = new RuntimePendingPickup(
Token: 0u,
itemGuid,
LocalEntityId: 0u,
destinationContainerId,
placement,
pendingPlacementToken,
ApproachToken: default);
if (_transactions.TryDispatchPickup(
contained,
_transport,
out uint containedSequence))
{
Console.WriteLine(
$"[B.5] contained pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={containedSequence}");
}
else
{
CancelPickupPresentation(itemGuid, pendingPlacementToken);
}
return;
}
if (!ValidatePickupTarget(itemGuid, showToast: true)
|| !_query.TryGetApproach(itemGuid, out InteractionApproach approach))
{
CancelPickupPresentation(itemGuid, pendingPlacementToken);
return;
}
if (approach.IsCloseRange)
{
bool armed = false;

View file

@ -261,7 +261,19 @@ internal sealed class WorldSelectionQuery
public VividTargetInfo? ResolveVividTargetInfo(uint serverGuid)
{
if (!_liveEntities.TryGetWorldEntity(serverGuid, out _)
uint playerGuid = _playerGuid();
ClientObject? target = _objects.Get(serverGuid);
// VividTargetIndicator::SetSelected @ 0x004F5CE0 deliberately keeps
// ACCWeenieObject::selectedID intact while suppressing its own marker
// for self, player-owned objects, and objects whose current state is
// IN_CONTAINER. ContainerId is our authoritative placement projection
// for that retail state.
if (serverGuid == playerGuid
|| target is null
|| _objects.IsOwnedByObject(serverGuid, playerGuid)
|| target.ContainerId != 0u
|| !_liveEntities.TryGetSpatiallyProjectedRecord(serverGuid, out _)
|| !TryGetSelectionSphere(serverGuid, out Vector3 center, out float radius))
{
return null;

View file

@ -27,7 +27,7 @@ internal readonly record struct AcceptedStateNetworkUpdate(
internal readonly record struct AcceptedPositionNetworkUpdate(
WorldSession.EntityPositionUpdate Update,
LiveEntityRecord Record,
RuntimeEntityRecord Canonical,
WorldSession.EntitySpawn Spawn,
AcceptedPhysicsTimestamps Timestamps,
PositionTimestampDisposition TimestampDisposition,
@ -173,22 +173,22 @@ internal sealed class LiveEntityInboundAuthorityGate
return false;
}
LiveEntityRecord? record = null;
RuntimeEntityRecord? canonical = null;
ulong positionAuthorityVersion = 0;
ulong velocityAuthorityVersion = 0;
if (disposition is not PositionTimestampDisposition.Rejected)
{
if (!_liveEntities.TryGetRecord(update.Guid, out record))
if (!_liveEntities.TryGetCanonical(update.Guid, out canonical))
return false;
positionAuthorityVersion = record.PositionAuthorityVersion;
velocityAuthorityVersion = record.VelocityAuthorityVersion;
positionAuthorityVersion = canonical.PositionAuthorityVersion;
velocityAuthorityVersion = canonical.VelocityAuthorityVersion;
}
_publishTimestamps(update.Guid, timestamps);
if (disposition is PositionTimestampDisposition.Rejected)
return false;
if (!_liveEntities.IsCurrentPositionAuthority(
record!,
canonical!,
positionAuthorityVersion))
{
return false;
@ -196,7 +196,7 @@ internal sealed class LiveEntityInboundAuthorityGate
accepted = new AcceptedPositionNetworkUpdate(
update,
record!,
canonical!,
spawn,
timestamps,
disposition,

View file

@ -61,6 +61,8 @@ internal sealed class LiveEntityNetworkUpdateController
private readonly ILiveWorldSessionSource _session;
private readonly LiveEntityInboundAuthorityGate _authorityGate;
private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics;
private readonly InventoryWorldDropProjectionController?
_worldDropProjection;
private PlayerMovementController? _playerController => _playerControllerSource.Controller;
private EntityPhysicsHost? _playerHost => _playerHostSource.Host;
@ -100,7 +102,8 @@ internal sealed class LiveEntityNetworkUpdateController
IPhysicsScriptTimeSource gameTime,
ILiveWorldSessionSource session,
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
IMovementTruthDiagnosticSink movementTruthDiagnostics)
IMovementTruthDiagnosticSink movementTruthDiagnostics,
InventoryWorldDropProjectionController? worldDropProjection = null)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
@ -135,6 +138,7 @@ internal sealed class LiveEntityNetworkUpdateController
publishTimestamps);
_movementTruthDiagnostics = movementTruthDiagnostics
?? throw new ArgumentNullException(nameof(movementTruthDiagnostics));
_worldDropProjection = worldDropProjection;
}
internal void ResetSessionState() => _authorityGate.ResetSessionState();
@ -936,6 +940,11 @@ internal sealed class LiveEntityNetworkUpdateController
public void OnPosition(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
if (_worldDropProjection?.TryRecoverUnknownPosition(update) == true)
{
return;
}
bool payloadIsValid = _projectileController?.CanAcceptPositionPayload(
update.Guid,
update.Position,
@ -957,11 +966,22 @@ internal sealed class LiveEntityNetworkUpdateController
var timestampDisposition = accepted.TimestampDisposition;
var acceptedSpawn = accepted.Spawn;
var timestamps = accepted.Timestamps;
LiveEntityRecord acceptedPositionRecord = accepted.Record;
RuntimeEntityRecord acceptedPositionCanonical = accepted.Canonical;
ulong acceptedPositionAuthorityVersion =
accepted.PositionAuthorityVersion;
ulong acceptedPositionVelocityAuthorityVersion =
accepted.VelocityAuthorityVersion;
if (!_liveEntities.TryGetProjection(
acceptedPositionCanonical,
out LiveEntityRecord acceptedPositionRecord)
&& !_liveEntityHydration.RecoverCanonicalProjection(
acceptedPositionCanonical,
acceptedPositionAuthorityVersion,
out acceptedPositionRecord))
{
return;
}
bool IsCurrentPositionOwner(
AcDream.Core.World.WorldEntity? expectedEntity = null) =>
_liveEntities.IsCurrentPositionAuthority(
@ -1013,7 +1033,12 @@ internal sealed class LiveEntityNetworkUpdateController
_playerController)))
return;
if (!_liveEntities.ContainsWorldEntity(update.Guid))
// A leave-world transition deliberately retains WorldEntity as the
// logical/render-resource owner while IsSpatiallyProjected is false.
// A fresh retail Position is the re-entry edge; testing only for the
// retained object reference leaves dropped inventory permanently
// invisible after InventoryPutObjectIn3D.
if (RequiresSpatialProjectionRecovery(acceptedPositionRecord))
{
if (!IsCurrentPositionOwner())
return;
@ -1771,6 +1796,13 @@ internal sealed class LiveEntityNetworkUpdateController
}
}
internal static bool RequiresSpatialProjectionRecovery(
LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(record);
return record.WorldEntity is null || !record.IsSpatiallyProjected;
}
// Retail teleport transit: the 7-state TAS drives portal/view-plane presentation, holds the
// player in PortalSpace until the destination is resident (TeleportWorldReady), then
// fires Place (materialize) and FireLoginComplete (regain control + ack the server).

View file

@ -993,7 +993,13 @@ public sealed class EquippedChildRenderController : IDisposable
private void OnObjectMoved(ClientObjectMove move)
{
if (move.Current.EquipLocation == EquipMask.None)
// ServerSaysMoveItem publishes both complete placements. Only an
// equipped -> unequipped transition retires an attached paperdoll
// projection. InventoryPutObjectIn3D also has EquipLocation=None, but
// it is a world-entry confirmation and must never withdraw a Position
// projection that arrived just before the F019A event.
if (move.Previous.EquipLocation != EquipMask.None
&& move.Current.EquipLocation == EquipMask.None)
BeginDetachedRemoval(move.ItemId);
}

View file

@ -179,6 +179,14 @@ public sealed class ItemInteractionController : IDisposable
/// </summary>
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementResolved;
/// <summary>
/// Publishes the exact split-to-world request after its wire send has
/// succeeded. Retail <c>ACCWeenieObject::UIAttemptSplitTo3D @
/// 0x0058D850</c> retains the source class, selected amount, and request
/// time so the subsequently created ground stack can be recognized.
/// </summary>
public event Action<WorldDropDispatch>? WorldDropDispatched;
public uint PlayerGuid => _playerGuid();
public InteractionState InteractionState => _interactionState;
@ -302,14 +310,10 @@ public sealed class ItemInteractionController : IDisposable
/// <summary>
/// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with
/// toolbar shortcut creation. Ownership includes self, equipped items, and
/// arbitrarily nested carried containers.
/// toolbar shortcut creation.
/// </summary>
public bool IsOwnedByPlayer(uint itemGuid)
=> _objects.Get(itemGuid) is { } item
&& (item.ObjectId == _playerGuid()
|| IsCarriedByPlayer(item)
|| IsEquippedByPlayer(item));
=> _objects.IsOwnedByObject(itemGuid, _playerGuid());
public bool IsCurrentTargetCompatible(uint targetGuid)
{
@ -721,6 +725,21 @@ public sealed class ItemInteractionController : IDisposable
return item is not null;
}
/// <summary>
/// Returns whether the object is a member of retail's current
/// <c>ClientUISystem::groundObject</c>. Such objects are container
/// contents, not independently projected world objects, so
/// <c>CPlayerSystem::PlaceInBackpack @ 0x0055D8C0</c> sends their
/// container transfer directly without a 3-D approach lookup.
/// </summary>
internal bool IsInCurrentGroundObject(uint objectId)
{
uint groundObjectId = _groundObjectId();
return groundObjectId != 0u
&& _objects.Get(objectId) is { } item
&& item.ContainerId == groundObjectId;
}
internal bool IsCurrentObjectIdentity(uint objectId, ClientObject item)
=> ReferenceEquals(_objects.Get(objectId), item);
@ -983,6 +1002,14 @@ public sealed class ItemInteractionController : IDisposable
if (_sendSplitToWorld is null)
return false;
_sendSplitToWorld(action.ObjectId, (uint)action.Amount);
if (_transactions.TryGetPending(out PendingInventoryRequest pending)
&& pending.Kind is InventoryRequestKind.SplitToWorld
&& pending.ItemId == action.ObjectId)
{
WorldDropDispatched?.Invoke(new WorldDropDispatch(
pending,
(uint)action.Amount));
}
return true;
});
break;
@ -1137,6 +1164,7 @@ public sealed class ItemInteractionController : IDisposable
_transactions.ObjectTableCleared -= OnInventoryObjectsCleared;
_transactions.RequestCompleted -= OnInventoryRequestCompleted;
_transactions.StateChanged -= OnTransactionStateChanged;
WorldDropDispatched = null;
_autoWield.Dispose();
}

View file

@ -0,0 +1,11 @@
using AcDream.Core.Items;
namespace AcDream.App.UI;
/// <summary>
/// One successfully dispatched inventory-to-world request together with the
/// quantity selected by the retained UI.
/// </summary>
public readonly record struct WorldDropDispatch(
PendingInventoryRequest Request,
uint Amount);

View file

@ -0,0 +1,216 @@
using AcDream.App.UI;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.App.World;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Retail records <c>splitClassID</c>, <c>splitStackSize</c>, and
/// <c>splitTime</c> in <c>ACCWeenieObject::UIAttemptSplitTo3D @
/// 0x0058D850</c>, then recognizes the created stack from
/// <c>ACCWeenieObject::DeclareValid @ 0x0058E340</c>. 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.
/// </remarks>
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<double> _now;
private bool _disposed;
public InventoryWorldDropProjectionController(
ItemInteractionController interaction,
ClientObjectTable objects,
LiveEntityRuntime runtime,
LiveEntityHydrationController hydration,
Func<double> 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();
}
/// <summary>
/// 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.
/// </summary>
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,
Timestamps = sourcePhysics.Timestamps with
{
Position = update.PositionSequence,
Teleport = update.TeleportSequence,
ForcePosition = update.ForcePositionSequence,
Instance = update.InstanceSequence,
},
}
: 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);
}

View file

@ -676,6 +676,66 @@ AppearanceSynchronization:
}
}
/// <summary>
/// Reconstructs the App projection for a canonical object whose accepted
/// retail Position moved it from inventory-only ownership into the world.
/// Logical identity, timestamps, setup defaults, and retained item state
/// already exist; this crosses only the ordinary materialization/ready
/// boundary.
/// </summary>
public bool RecoverCanonicalProjection(
RuntimeEntityRecord expectedCanonical,
ulong positionAuthorityVersion,
out LiveEntityRecord record)
{
ArgumentNullException.ThrowIfNull(expectedCanonical);
lock (_datLock)
{
record = null!;
if (!_runtime.IsCurrentPositionAuthority(
expectedCanonical,
positionAuthorityVersion))
{
return false;
}
if (_runtime.TryGetProjection(
expectedCanonical,
out record))
{
return _runtime.IsCurrentPositionAuthority(
record,
positionAuthorityVersion);
}
WorldSession.EntitySpawn canonicalSpawn =
expectedCanonical.Snapshot;
if (canonicalSpawn.Position is null)
return false;
InitializeOriginAndRecoverLoaded(canonicalSpawn);
if (!_runtime.IsCurrentPositionAuthority(
expectedCanonical,
positionAuthorityVersion)
|| !ProjectExact(
expectedCanonical,
canonicalSpawn,
LiveProjectionPurpose.SpatialRecovery,
expectedCanonical.CreateIntegrationVersion)
|| !_runtime.TryGetProjection(
expectedCanonical,
out record))
{
record = null!;
return false;
}
return _runtime.IsCurrentPositionAuthority(
record,
positionAuthorityVersion);
}
}
public bool OnEntityReady(LiveEntityReadyCandidate candidate)
{
if (!PublishReady(candidate))

View file

@ -1154,6 +1154,29 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return false;
}
/// <summary>
/// Resolves a current top-level world projection even when its spatial
/// bucket is pending/nonresident. Unlike interaction eligibility, this
/// does not impose render visibility; unlike <see cref="TryGetWorldEntity"/>,
/// it rejects a retained leave-world owner whose pose is historical.
/// </summary>
public bool TryGetSpatiallyProjectedRecord(
uint serverGuid,
out LiveEntityRecord record)
{
if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord found)
&& found.WorldEntity is not null
&& found.ProjectionKind is LiveEntityProjectionKind.World
&& found.IsSpatiallyProjected)
{
record = found;
return true;
}
record = null!;
return false;
}
/// <summary>
/// Resolves a top-level object that currently participates in picking,
/// targeting, radar, and wire-driven MoveTo/Sticky establishment.
@ -1762,6 +1785,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
&& ReferenceEquals(current, record)
&& current.PositionAuthorityVersion == authorityVersion;
internal bool IsCurrentPositionAuthority(
RuntimeEntityRecord canonical,
ulong authorityVersion) =>
_directory.IsCurrent(canonical)
&& canonical.PositionAuthorityVersion == authorityVersion;
internal bool IsCurrentStateAuthority(
LiveEntityRecord record,
ulong authorityVersion) =>
@ -1783,6 +1812,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
&& ReferenceEquals(current, record)
&& current.VelocityAuthorityVersion == authorityVersion;
internal bool IsCurrentVelocityAuthority(
RuntimeEntityRecord canonical,
ulong authorityVersion) =>
_directory.IsCurrent(canonical)
&& canonical.VelocityAuthorityVersion == authorityVersion;
internal bool IsCurrentMovementAuthority(
LiveEntityRecord record,
ulong authorityVersion) =>

View file

@ -214,6 +214,40 @@ public sealed class ClientObjectTable
public ClientObject? Get(uint objectId) =>
_objects.TryGetValue(objectId, out var item) ? item : null;
/// <summary>
/// Retail <c>ACCWeenieObject::IsOwnedByObject @ 0x0058CEB0</c>.
/// Direct containment/wielding counts, as does an item inside one of the
/// owner's authored pack containers or on one of the owner's locations.
/// </summary>
public bool IsOwnedByObject(uint objectId, uint ownerId)
{
if (ownerId == 0u
|| !_objects.TryGetValue(objectId, out ClientObject? item))
{
return false;
}
if (item.ObjectId == ownerId
|| item.ContainerId == ownerId
|| item.WielderId == ownerId)
{
return true;
}
if (!_objects.ContainsKey(ownerId))
return false;
if (item.ContainerId != 0u
&& _containerIndex.TryGetValue(ownerId, out List<uint>? ownerContents)
&& ownerContents.Contains(item.ContainerId))
{
return true;
}
return _equipmentIndex.TryGetValue(ownerId, out List<uint>? ownerLocations)
&& ownerLocations.Contains(item.ObjectId);
}
/// <summary>
/// Look up a container by object id, creating a lightweight stub if
/// the id doesn't match any known container (defensive — avoids losing
@ -297,26 +331,43 @@ public sealed class ClientObjectTable
newWielderId,
newEquipLocation),
containerTypeHint,
ClientObjectMoveOrigin.AuthoritativeResponse);
ClientObjectMoveOrigin.AuthoritativeResponse,
retailContainerInsert: true);
}
private bool ApplyPlacement(
ClientObject item,
ClientObjectPlacement current,
uint? containerTypeHint,
ClientObjectMoveOrigin origin)
ClientObjectMoveOrigin origin,
bool retailContainerInsert = false)
{
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
item.ObjectId,
current.ContainerId);
bool previousWasContainer = IsContainerListMember(item);
List<uint>? changedContainers;
if (retailContainerInsert)
{
changedContainers = RemoveFromAllContainerIndexes(
item.ObjectId,
current.ContainerId,
previousWasContainer);
}
else
{
changedContainers = RemoveFromOtherContainerIndexes(
item.ObjectId,
current.ContainerId);
}
item.ContainerId = current.ContainerId;
item.ContainerSlot = current.ContainerSlot;
item.WielderId = current.WielderId;
item.CurrentlyEquippedLocation = current.EquipLocation;
if (containerTypeHint is { } hint)
item.ContainerTypeHint = hint;
Reindex(item, previous.ContainerId);
if (retailContainerInsert && item.ContainerId != 0u)
InsertContainerMember(item, current.ContainerSlot);
else
Reindex(item, previous.ContainerId);
PublishPlacementChange(item, previous, origin);
PublishContainerContentsChanges(changedContainers);
return true;
@ -927,6 +978,101 @@ public sealed class ClientObjectTable
return changed;
}
/// <summary>
/// Retail <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c>
/// removes the object from its old <c>IDList</c> before
/// <c>AddContent @ 0x0058CCE0</c> inserts it at the server-supplied list
/// index. Remove the canonical member as well as stale ViewContents
/// projections so an authoritative same-container move can perform that
/// exact remove-then-insert transition.
/// </summary>
private List<uint>? RemoveFromAllContainerIndexes(
uint itemId,
uint destinationContainerId,
bool wasContainer)
{
List<uint>? changed = null;
foreach ((uint containerId, List<uint> members) in _containerIndex)
{
if (!members.Remove(itemId))
continue;
RenumberContainerCategory(members, wasContainer);
if (containerId != destinationContainerId)
(changed ??= new List<uint>()).Add(containerId);
}
return changed;
}
/// <summary>
/// Port of retail <c>ACCWeenieObject::AddContent @ 0x0058CCE0</c>:
/// items and child containers have separate ordered <c>IDList</c>s and
/// <c>IDList::AddAtNum</c> clamps the requested index to the list length.
/// Our compact projection stores both categories in one list, so insertion
/// and renumbering operate only inside the matching retail category.
/// </summary>
private void InsertContainerMember(ClientObject item, int requestedSlot)
{
if (!_containerIndex.TryGetValue(item.ContainerId, out List<uint>? members))
_containerIndex[item.ContainerId] = members = new List<uint>();
bool isContainer = IsContainerListMember(item);
int categoryCount = 0;
int insertionIndex = members.Count;
int requestedIndex = requestedSlot < 0 ? int.MaxValue : requestedSlot;
for (int i = 0; i < members.Count; i++)
{
if (!_objects.TryGetValue(members[i], out ClientObject? existing)
|| IsContainerListMember(existing) != isContainer)
{
continue;
}
if (categoryCount == requestedIndex)
{
insertionIndex = i;
break;
}
categoryCount++;
insertionIndex = i + 1;
}
members.Insert(insertionIndex, item.ObjectId);
int publishedSlot = requestedSlot < 0 ? categoryCount : requestedSlot;
RenumberContainerCategory(
members,
isContainer,
preservedItemId: item.ObjectId,
preservedSlot: publishedSlot);
}
private void RenumberContainerCategory(
List<uint> members,
bool isContainer,
uint preservedItemId = 0u,
int preservedSlot = -1)
{
int slot = 0;
for (int i = 0; i < members.Count; i++)
{
if (_objects.TryGetValue(members[i], out ClientObject? member)
&& IsContainerListMember(member) == isContainer)
{
member.ContainerSlot = member.ObjectId == preservedItemId
? preservedSlot
: slot;
slot++;
}
}
}
private static bool IsContainerListMember(ClientObject item) =>
item.ContainerTypeHint != 0u
|| (item.Type & ItemType.Container) != 0
|| item.ItemsCapacity > 0;
private void PublishContainerContentsChanges(List<uint>? changed)
{
if (changed is null || changed.Count == 0)