Found by C4 route 6's integration tests (1b484937) and split out of that zero-production closure per the standing split-on-discovery rule. PendingSplitToWorldProjection.BuildSpawn zeroes the top-level MovementSequence/ServerControlSequence, but its Physics.Timestamps `with` block overrode only Position/Teleport/ForcePosition/Instance — leaving Timestamps.Movement and .ServerControlledMove at the SOURCE item's values. RuntimeEntityObjectLifetime.HasConsistentCreateIdentityAndParent requires the PhysicsDesc timestamps and their flattened projections to agree, so the synthetic spawn failed the predicate and TryRecoverUnknownPosition threw `CreateObject 0x… has inconsistent instance or parent projections` instead of completing the canonical create-placement transaction. Reachable in ordinary play: retail's per-object update_times channels are monotonic and do not reset when an item re-enters a container, so any item that ever had world presence — dropped once, picked back up, then split — carries nonzero values in exactly those two fields. The split pile then never appears. Fix is the honest value, not a placation of the predicate: a fresh split GUID has no movement history by construction, so both channels are zero in both projections. Deliberately NOT fixed by loosening HasConsistentCreateIdentityAndParent — the predicate was right and the producer was wrong. The route-6 test that documented the throw (SplitSourceWithRetainedMovementTimestamps_ThrowsInsteadOfRecovering) is renamed to …_StillRecovers and now pins the fix. It asserts more than "no throw": the result's movement channels must be ZERO in both projections, so the test cannot pass against a lenient-predicate workaround. Sabotage-verified in both directions — restoring the old BuildSpawn reproduces the exact original InvalidOperationException. Notable for the campaign record: this is a crash in the precise mechanism route 6's scoping cited as EVIDENCE that drops already converge on the canonical transaction. Reading the code said the path converges; driving it said it throws. The zero-production route was still correct — and building its tests anyway is what found this. Complete Release suite 11,020 passed / 4 skipped / 0 failed, unchanged from1b484937(the test flipped its assertion rather than being added). Neither known flake fired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
8 KiB
C#
232 lines
8 KiB
C#
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,
|
|
// #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);
|
|
}
|