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

@ -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) =>