Follow-up to C5b (735f0a72). The retail-conformance review passed, so no production behaviour line moved: the flag truth table and the refreshPosition:false withhold are untouched. This is blast radius, test discrimination and documentation fidelity - plus two findings I could not confirm and am rebutting rather than complying with. D3 - THE PUBLISH-CONSERVATION TEST DID NOT DISCRIMINATE. The reviewer was right and it was the worst finding here: proof obligation 3's test passed identically with C5b reverted. Its only delta assertion FILTERED (Assert.Single(deltas, Rebucketed && parentGuid)), so the pre-C5b stream [Rebucketed] and the post-C5b stream [Updated, Rebucketed] both satisfied it, and childSpatialBefore+1 held in both worlds because whichever site did not move the cell propagated idempotently. It now asserts the complete ordered parent stream plus each element's CellId and Position.ObjCellId. Sabotage: restoring refreshPosition:acceptedPosition turns it red (it was green before), together with the withhold test and the new L5 test. That cardinality change was itself unfiled and is now AP-147: a cell-changing accepted Position publishes TWO entity deltas where it published one, and the intermediate Updated pairs the OLD CellId with the NEW wire Position - a torn pair that did not exist pre-C5b, since both halves used to move inside one publish. No production consumer reads a delta's paired fields, but a recorder/plugin/bot event log would capture it. The row states why suppressing the Updated is not available at that layer (the merge cannot know whether its caller reaches W2). D4 - THE PROJECTILE DOC COMMENT WAS FALSE AND ITS RETAIL ARGUMENT WAS INVERTED. SyncPresentationFromResolvedBody claimed record.FullCellId is "the WIRE cell ... stamped by the merge's RefreshDerivedState/SetFullCell, before classification ever runs" and argued from retail's store_position @0x00515CE2 that the destination cell is the right one. C5b falsified the premise; the missile arm also returns before W2, so nothing stamps the wire cell for a projectile at all. Rewritten. The honest conclusion, which the old text would have called wrong: on a stored outcome presentation now pairs the DESTINATION world position with the SOURCE cell. That is not a choice this method can make differently - StoreAcceptedDestinationPose writes only Position/Orientation, so record.FullCellId and body.CellPosition.ObjCellId now hold the same source cell and reading either yields the same value. The divergence is AP-138 item (1)'s store-writes-pose-but-not-cell residual, retiring via #309, not a field choice here. Projecting the wire cell instead would invent a residency the placement declined - the AP-1 shape C5b closed. L3/L4/L5 - PINNING GAPS, ALL THREE CONFIRMED AND CLOSED. L3: the matrix's oracle passed HasAnimations as a literal, so the merge's old.MotionTableId ?? old.Physics?.MotionTableId and RuntimeAcceptedPositionRouteRequests.Build's canonical-snapshot twin were textually identical and pinned by nothing. The oracle is now BUILT by the production constructor. L4: every fixture set both MotionTableId halves to the same value, so deleting either operand of the ?? was undetectable while the production comment said the mixed case is the real-world one. Six mixed rows added, including the explicit-zero row (a present-but-zero top half is not null, so ?? never reaches the physics half). L5: the retained Rebucketed ternary had zero coverage through TryApplyPosition - every restoreCancelledPark test called Forget directly. Now driven through the real merge, with the wire cell deliberately the SOURCE while the park's committed body cell is the DESTINATION, so the restored residency can only have come from the rollback. Sabotage (each red, each restored): merge ?? -> top half only, 1 red; -> physics half only, 2 red; Build's ?? -> physics half only, 2 red; ternary -> constant Updated, exactly the L5 test red. L1/L2 - THE MISSING TEST IS ADDED; THE DEFECT IS NOT THERE. The reviewer was right that C5b's "no fixture covers pickup at that layer" was inaccurate - LiveEntityNetworkOnPositionCollapseMatrixTests drives the real OnPosition at ~26 sites - and the end-to-end test is added: withdraw -> accepted Position -> IsSpatiallyProjected && FullCellId == wireCell, both guid classes. But ChildUnparentDisposition.Pending is NOT a live defect, because it is production-unreachable. The sole production _withdrawProjection binding (LivePresentationComposition.cs:599) is LiveEntityProjectionWithdrawalController.WithdrawExact, whose only Pending mint is inside its catch block and therefore always carries a non-null Failure - and AdvanceUnparentTransition rethrows at EquippedChildRenderController.cs:1307 BEFORE the return Pending at :1309. The named drop scenario does not reach it anyway (BeginDetachedRemoval has already emptied the capture list) and would be correct if it did: a previously-equipped child is LegacyImmediate, so the FullCellId != 0u gate at DatLiveEntityProjectionMaterializer.cs:767 is never consulted and re-projection uses the wire cell at LiveEntityRuntime.cs:824. Measured while building that test, and NOT what C5b assumed: W2 and W3 are REDUNDANT on the remote tail. Sabotaging W2 alone - adopting the committed cell instead of the wire cell, OR skipping the rebucket outright - leaves the whole file green, because W3's RemoteMotion.CellId write reads through to canonical FullCellId via CommitCanonicalCell, whose CellCommitted recovery re-installs the bucket. Only removing BOTH goes red, and then the new test is the only red in the file. So it is named for what it pins, and AD-60 is amended with the measurement: neither channel is individually load-bearing, so a future retirement of one is caught by nothing else. D2 - REBUTTED, WITH THE REAL GAP FILED INSTEAD. The reviewer's hypothesis was that TryApplyInitialCreateCompletionPresentation's staleness guard lost its ability to detect an intervening steady-state Position when C5b stopped the merge stamping the wire cell, and asked for a PositionAuthorityVersion term. I do not think that is right and did not add it. The receipt's facts are the canonical BODY's pose and cell at publish (PublishExecutorCompletion builds both from the record). Exactly two owners can move them: a Runtime SetPosition commit/withdrawal, every one of which calls AdvancePlacementCommit - the only caller family is RuntimeSetPositionState - and a rebucket, which moves FullCellId. Both are already covered by the two existing terms. An accepted steady-state Position is neither, and C5b did not make it one: the merge refreshes the snapshot and advances PositionAuthorityVersion but never wrote the body, and the App generic tail writes the RENDER entity. The wire-cell half stays covered because W2/W3 commit it in the same call; the paths that return before them leave the record at the last committed cell, which IS the receipt's own cell - correctly not a supersession. Adding the term would decline receipts whose facts are still true, on the entity's FIRST world-visible moment: the pose write and RebucketLiveEntityPresentationOnly would be skipped while TryPublishPlace still publishes, so a packet returning before the render write would leave the sidecar visible at its materialized pose in a wrong bucket. That is the handoff's own "removed the invariant failure while leaving the bug" shape. There IS one supersession neither term covers, and it predates C5b: RuntimeRemotePlacementDriveController.StoreAcceptedDestinationPose writes body.Position/Orientation on the far-snap Refused/Contention arm with no placement commit and no cell move. Filed as #323 with the FIFO-blocking argument for why a receipt can still be pending when it lands, an explicit "not established as reachable", and an explicit "do not fix it with PositionAuthorityVersion". The guard's comment now carries the whole argument instead of one sentence. S1 - DANGLING POINTER CLOSED. InboundPhysicsStateController.cs:610 still said the two-callers-one-rule debt was "tracked for the eventual cutover unification ... See docs/ISSUES.md", which pointed at nothing after C5b closed #275 without a successor. Filed #322, cited from both the comment and #275's closure, including why widening TryApplyPosition's signature to take a route would be the wrong unification. AP-138 amended: C5b staled its round-3 measurement that "both accepted-Position callers commit the accepted wire cell to record.FullCellId before submitting". Route 2 submits from TryExecuteAcceptedLocalPosition ahead of W2, so on a first submit PlacementTouchesPrefix's CurrentCellId arm now names the SOURCE landblock, not the destination. Confined to which prefix the quiescence pre-flight matches, which that row already established is not the correctness mechanism. GATES. Release build 0 errors. Complete suite 11,134 passed / 4 skipped / 0 failed, from the 11,125 / 4 baseline ated806997: net +9, all new tests, no test deleted or weakened, no new skip. Runtime.Tests 1195 -> 1202 (+6 mixed-motion-table rows, +1 park-rollback fact); App.Tests 4132 -> 4134 (+2 guid rows). None of #302/#308/#321 appeared. Not connected-gated - nothing here changes runtime behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3758 lines
147 KiB
C#
3758 lines
147 KiB
C#
using AcDream.App.Streaming;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.World;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Physics;
|
|
using System.Numerics;
|
|
using System.Runtime.ExceptionServices;
|
|
|
|
namespace AcDream.App.World;
|
|
|
|
/// <summary>
|
|
/// Animation state owned by a live object. The concrete App animation runtime
|
|
/// implements this seam so identity storage does not depend on a render backend.
|
|
/// </summary>
|
|
public interface ILiveEntityAnimationRuntime
|
|
{
|
|
WorldEntity Entity { get; }
|
|
uint CurrentMotion { get; }
|
|
}
|
|
|
|
/// <summary>Marker for the DAT-driven effect profile added by the effect phase.</summary>
|
|
public interface ILiveEntityEffectProfile { }
|
|
|
|
public enum LiveEntityProjectionKind
|
|
{
|
|
World,
|
|
Attached,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Selects whether App may publish a newly constructed sidecar immediately or
|
|
/// must keep it non-spatial until the canonical Runtime SetPosition receipt is
|
|
/// projected. The latter is the production CreateObject path; the former is a
|
|
/// temporary compatibility seam for callers not yet cut over in Slice 4B2.
|
|
/// </summary>
|
|
internal enum LiveEntityMaterializationResidence
|
|
{
|
|
LegacyImmediate,
|
|
AwaitRuntimePlacement,
|
|
}
|
|
|
|
/// <summary>
|
|
/// C4 route 7 A2/R3 remediation:
|
|
/// <see cref="LiveEntityRuntime.RebucketEquippedChildPresentation"/>'s typed
|
|
/// outcome. <see cref="Moved"/> is the ordinary case. <see cref="NotAttached"/>
|
|
/// is the KNOWN-benign decline — the child's Runtime relation already unwound
|
|
/// (pickup/Position-unparent, or an active initial-create residence) while
|
|
/// <c>EquippedChildRenderController._attachedByChild</c> still holds it, ahead
|
|
/// of <c>OnChildBecameUnparented</c>'s own teardown — callers must not treat it
|
|
/// as success.
|
|
///
|
|
/// <para>
|
|
/// Round-3 review (B6): <see cref="NoProjection"/> and <see cref="Displaced"/>
|
|
/// were originally ONE case and are now split, because they mean different
|
|
/// things and warrant different caller behavior. <see cref="NoProjection"/> —
|
|
/// no <c>LiveEntityRecord</c>/<c>WorldEntity</c> exists for this guid AT ALL
|
|
/// despite <c>_attachedByChild</c> holding the key — is NOT expected and is
|
|
/// treated as a genuine pose-loss failure. <see cref="Displaced"/> — the
|
|
/// spatial rebucket STARTED but a re-entrant caller superseded this exact
|
|
/// projection operation before it finished (<c>IsCurrentProjectionOperation</c>
|
|
/// went false mid-call, inside <c>RebucketLiveEntityPresentationOnly</c>) —
|
|
/// means the record was current a moment ago and some OTHER, newer operation
|
|
/// already committed a different outcome for it. Unlike
|
|
/// <see cref="NoProjection"/>, this is treated as BENIGN, the same as
|
|
/// <see cref="NotAttached"/> (see <c>TickChild</c>): a superseded operation
|
|
/// is not evidence anything is wrong with the entity, only that a fresher
|
|
/// write already ran — tearing it down as pose loss would act on a stale
|
|
/// snapshot against a projection a newer, presumably-valid operation just
|
|
/// updated.
|
|
/// </para>
|
|
/// </summary>
|
|
internal enum EquippedChildPresentationRebucketDisposition
|
|
{
|
|
Moved,
|
|
NotAttached,
|
|
NoProjection,
|
|
Displaced,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logical-resource seam coordinated by <see cref="LiveEntityRuntime"/>.
|
|
/// Spatial bucketing is deliberately absent: registering or removing meshes,
|
|
/// scripts, and effects is a logical-lifetime operation, not a landblock move.
|
|
/// </summary>
|
|
public interface ILiveEntityResourceLifecycle
|
|
{
|
|
void Register(WorldEntity entity);
|
|
void Unregister(WorldEntity entity);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exact-incarnation App component cleanup invoked by the canonical runtime
|
|
/// after it has retired the active identity. Mesh/script resource lifetime is
|
|
/// owned separately by <see cref="ILiveEntityResourceLifecycle"/>.
|
|
/// </summary>
|
|
internal interface ILiveEntityRuntimeComponentLifecycle
|
|
{
|
|
void TearDown(LiveEntityRecord record);
|
|
}
|
|
|
|
internal sealed class NullLiveEntityRuntimeComponentLifecycle :
|
|
ILiveEntityRuntimeComponentLifecycle
|
|
{
|
|
public static NullLiveEntityRuntimeComponentLifecycle Instance { get; } = new();
|
|
private NullLiveEntityRuntimeComponentLifecycle() { }
|
|
public void TearDown(LiveEntityRecord record) { }
|
|
}
|
|
|
|
internal sealed class DelegateLiveEntityRuntimeComponentLifecycle(
|
|
Action<LiveEntityRecord> tearDown) : ILiveEntityRuntimeComponentLifecycle
|
|
{
|
|
private readonly Action<LiveEntityRecord> _tearDown =
|
|
tearDown ?? throw new ArgumentNullException(nameof(tearDown));
|
|
|
|
public void TearDown(LiveEntityRecord record) => _tearDown(record);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Composition bridge for the runtime-to-hydration construction cycle. It is
|
|
/// deliberately fail-fast and single-assignment: a live session must not start
|
|
/// before the exact component owner is bound, and ownership cannot be replaced
|
|
/// during a session.
|
|
/// </summary>
|
|
internal sealed class DeferredLiveEntityRuntimeComponentLifecycle :
|
|
ILiveEntityRuntimeComponentLifecycle
|
|
{
|
|
private ILiveEntityRuntimeComponentLifecycle? _inner;
|
|
|
|
public bool IsBound => Volatile.Read(ref _inner) is not null;
|
|
|
|
public void Bind(ILiveEntityRuntimeComponentLifecycle lifecycle)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(lifecycle);
|
|
if (ReferenceEquals(lifecycle, this))
|
|
throw new ArgumentException("A lifecycle bridge cannot bind to itself.", nameof(lifecycle));
|
|
if (Interlocked.CompareExchange(ref _inner, lifecycle, null) is not null)
|
|
throw new InvalidOperationException("The live-entity runtime component lifecycle is already bound.");
|
|
}
|
|
|
|
public IDisposable BindOwned(ILiveEntityRuntimeComponentLifecycle lifecycle)
|
|
{
|
|
Bind(lifecycle);
|
|
return new Binding(this, lifecycle);
|
|
}
|
|
|
|
private void Unbind(ILiveEntityRuntimeComponentLifecycle expected)
|
|
{
|
|
_ = Interlocked.CompareExchange(ref _inner, null, expected);
|
|
}
|
|
|
|
public void TearDown(LiveEntityRecord record)
|
|
{
|
|
ILiveEntityRuntimeComponentLifecycle lifecycle = Volatile.Read(ref _inner)
|
|
?? throw new InvalidOperationException(
|
|
"The live-entity runtime component lifecycle has not been bound.");
|
|
lifecycle.TearDown(record);
|
|
}
|
|
|
|
private sealed class Binding : IDisposable
|
|
{
|
|
private DeferredLiveEntityRuntimeComponentLifecycle? _owner;
|
|
private readonly ILiveEntityRuntimeComponentLifecycle _expected;
|
|
|
|
public Binding(
|
|
DeferredLiveEntityRuntimeComponentLifecycle owner,
|
|
ILiveEntityRuntimeComponentLifecycle expected)
|
|
{
|
|
_owner = owner;
|
|
_expected = expected;
|
|
}
|
|
|
|
public void Dispose() =>
|
|
Interlocked.Exchange(ref _owner, null)?.Unbind(_expected);
|
|
}
|
|
}
|
|
|
|
/// <summary>Delegate adapter used by the App composition root.</summary>
|
|
public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLifecycle
|
|
{
|
|
private readonly Action<WorldEntity> _register;
|
|
private readonly Action<WorldEntity> _unregister;
|
|
|
|
public DelegateLiveEntityResourceLifecycle(
|
|
Action<WorldEntity> register,
|
|
Action<WorldEntity> unregister)
|
|
{
|
|
_register = register ?? throw new ArgumentNullException(nameof(register));
|
|
_unregister = unregister ?? throw new ArgumentNullException(nameof(unregister));
|
|
}
|
|
|
|
public void Register(WorldEntity entity) => _register(entity);
|
|
public void Unregister(WorldEntity entity) => _unregister(entity);
|
|
}
|
|
|
|
/// <summary>
|
|
/// App projection sidecar for one canonical <see cref="RuntimeEntityRecord"/>.
|
|
/// The canonical record survives loaded/pending landblock movement and
|
|
/// temporary loss of its render bucket; this sidecar retains only graphical
|
|
/// components, visibility, hydration, and teardown progress.
|
|
/// </summary>
|
|
public sealed class LiveEntityRecord
|
|
{
|
|
private readonly RuntimeEntityDirectory _directory;
|
|
|
|
internal LiveEntityRecord(
|
|
RuntimeEntityDirectory directory,
|
|
RuntimeEntityRecord canonical)
|
|
{
|
|
_directory = directory ?? throw new ArgumentNullException(nameof(directory));
|
|
Canonical = canonical ?? throw new ArgumentNullException(nameof(canonical));
|
|
}
|
|
|
|
internal RuntimeEntityRecord Canonical { get; }
|
|
internal RuntimeEntityKey? ProjectionKey { get; set; }
|
|
public uint ServerGuid => Canonical.ServerGuid;
|
|
public ushort Generation => Canonical.Incarnation;
|
|
public WorldSession.EntitySpawn Snapshot
|
|
{
|
|
get => Canonical.Snapshot;
|
|
internal set => _directory.RefreshSnapshot(Canonical, value);
|
|
}
|
|
public WorldEntity? WorldEntity { get; internal set; }
|
|
public uint? LocalEntityId => Canonical.LocalEntityId ?? WorldEntity?.Id;
|
|
public uint FullCellId
|
|
{
|
|
get => Canonical.FullCellId;
|
|
internal set => _directory.SetFullCell(
|
|
Canonical,
|
|
value,
|
|
Canonical.CanonicalLandblockId);
|
|
}
|
|
public uint CanonicalLandblockId
|
|
{
|
|
get => Canonical.CanonicalLandblockId;
|
|
internal set => _directory.SetFullCell(
|
|
Canonical,
|
|
Canonical.FullCellId,
|
|
value);
|
|
}
|
|
public uint RawPhysicsState => Canonical.RawPhysicsState;
|
|
public PhysicsStateFlags FinalPhysicsState
|
|
{
|
|
get => Canonical.FinalPhysicsState;
|
|
internal set => _directory.SetFinalPhysicsState(Canonical, value);
|
|
}
|
|
/// <summary>
|
|
/// Incarnation-stable retail <c>CPhysicsObj::update_time</c> owner. It
|
|
/// survives animation and MovementManager attachment/replacement, spatial
|
|
/// rebucketing, and appearance rehydration, and dies with this record.
|
|
/// </summary>
|
|
public RetailObjectQuantumClock ObjectClock => Canonical.ObjectClock;
|
|
/// <summary>
|
|
/// Changes whenever this record leaves or re-enters the ordinary-object
|
|
/// workset. Unlike <see cref="ProjectionMutationVersion"/>, loaded-to-loaded
|
|
/// rebucketing does not change this token. A scheduler can therefore stop a
|
|
/// catch-up batch that crossed a leave/enter boundary without rejecting an
|
|
/// ordinary same-frame cell move.
|
|
/// </summary>
|
|
internal ulong ObjectClockEpoch => Canonical.ObjectClockEpoch;
|
|
/// <summary>
|
|
/// True after this incarnation has successfully constructed its retail
|
|
/// <c>CPartArray</c>. This is an object-lifetime fact, not an animation-
|
|
/// runtime proxy: static poses and objects without a MotionTable still
|
|
/// own a PartArray and participate in the 96-unit Active gate.
|
|
/// </summary>
|
|
public bool HasPartArray
|
|
{
|
|
get => Canonical.HasPartArray;
|
|
internal set => _directory.SetHasPartArray(Canonical, value);
|
|
}
|
|
public PhysicsBody? PhysicsBody
|
|
{
|
|
get => Canonical.PhysicsBody;
|
|
}
|
|
internal bool PhysicsBodyAcquisitionInProgress
|
|
{
|
|
get => Canonical.PhysicsBodyAcquisitionInProgress;
|
|
}
|
|
public ILiveEntityAnimationRuntime? AnimationRuntime { get; internal set; }
|
|
public IRuntimeRemoteMotion? RemoteMotionRuntime
|
|
{
|
|
get => Canonical.RemoteMotion;
|
|
}
|
|
internal bool RemoteMotionBindingInProgress
|
|
{
|
|
get => Canonical.RemoteMotionBindingInProgress;
|
|
}
|
|
public AcDream.Core.Physics.Motion.IPhysicsObjHost? PhysicsHost
|
|
{
|
|
get => Canonical.PhysicsHost;
|
|
}
|
|
internal bool RequiresRemotePlacementRuntime
|
|
{
|
|
get => Canonical.RequiresRemotePlacementRuntime;
|
|
}
|
|
public IRuntimeProjectile? ProjectileRuntime => Canonical.Projectile;
|
|
public ILiveEntityEffectProfile? EffectProfile { get; internal set; }
|
|
public bool ResourcesRegistered { get; internal set; }
|
|
internal LiveEntityMaterializationResidence? MaterializationResidence
|
|
{ get; set; }
|
|
public bool IsSpatiallyProjected { get; internal set; }
|
|
public bool IsSpatiallyVisible { get; internal set; }
|
|
internal ulong ProjectionMutationVersion { get; set; }
|
|
/// <summary>
|
|
/// Monotonic App operation tokens for accepted wire authority. They let a
|
|
/// handler detect a newer same-incarnation packet delivered re-entrantly
|
|
/// while an older packet is crossing presentation or spatial callbacks.
|
|
/// </summary>
|
|
internal ulong PositionAuthorityVersion => Canonical.PositionAuthorityVersion;
|
|
internal ulong StateAuthorityVersion => Canonical.StateAuthorityVersion;
|
|
internal ulong VectorAuthorityVersion => Canonical.VectorAuthorityVersion;
|
|
internal ulong VelocityAuthorityVersion => Canonical.VelocityAuthorityVersion;
|
|
internal ulong MovementAuthorityVersion => Canonical.MovementAuthorityVersion;
|
|
internal ulong ObjDescAuthorityVersion => Canonical.ObjDescAuthorityVersion;
|
|
/// <summary>
|
|
/// Advances for every accepted CreateObject affecting this incarnation,
|
|
/// including a fresher same-INSTANCE_TS retransmit. App integration uses
|
|
/// it to distinguish two transactions that intentionally share identity.
|
|
/// </summary>
|
|
internal ulong CreateIntegrationVersion => Canonical.CreateIntegrationVersion;
|
|
public bool WorldSpawnPublished { get; internal set; }
|
|
/// <summary>
|
|
/// True only after the incarnation's projection, collision/animation
|
|
/// components, effect owner, state presentation, and pending-effect replay
|
|
/// have crossed the initial ready barrier. A non-null
|
|
/// <see cref="WorldEntity"/> alone is not sufficient: failures after
|
|
/// resource registration retain the same projection for exact retry.
|
|
/// </summary>
|
|
public bool InitialHydrationCompleted { get; internal set; }
|
|
/// <summary>
|
|
/// A fresher same-incarnation CreateObject interrupted projection after
|
|
/// logical ownership had already been established. The retained entity
|
|
/// must replay the newest canonical appearance/current snapshot/animation
|
|
/// before ordinary rebucketing can resume. This obligation deliberately
|
|
/// survives a failed recovery attempt.
|
|
/// </summary>
|
|
internal bool CreateProjectionSynchronizationPending { get; set; }
|
|
internal bool ProjectionHydrationInProgress { get; set; }
|
|
internal ulong ProjectionHydrationCreateVersion { get; set; }
|
|
internal bool ProjectionHydrationRetryRequested { get; set; }
|
|
/// <summary>
|
|
/// A renderer mesh transaction can be re-entered by a newer accepted
|
|
/// ObjDesc. Keep that independent projection obligation on the exact
|
|
/// incarnation until the newest visual description publishes.
|
|
/// </summary>
|
|
internal bool AppearanceProjectionSynchronizationPending { get; set; }
|
|
internal bool AppearanceHydrationInProgress { get; set; }
|
|
internal ulong AppearanceHydrationVersion { get; set; }
|
|
internal bool AppearanceHydrationRetryRequested { get; set; }
|
|
public LiveEntityProjectionKind ProjectionKind { get; internal set; }
|
|
internal bool RuntimeComponentsTeardownCompleted { get; set; }
|
|
internal LiveEntityTeardownPlan? RuntimeComponentTeardownPlan { get; set; }
|
|
internal bool SpatialProjectionTeardownCompleted { get; set; }
|
|
internal bool TeardownInProgress { get; set; }
|
|
internal bool DeleteAcceptedForTeardown
|
|
{
|
|
get => Canonical.DeleteAcceptedForTeardown;
|
|
set => _directory.SetDeleteAcceptedForTeardown(Canonical, value);
|
|
}
|
|
|
|
internal bool TryDequeueStateTransition(out RetailPhysicsStateTransition transition) =>
|
|
_directory.TryDequeueStateTransition(Canonical, out transition);
|
|
|
|
internal void SuspendObjectClock() => _directory.SuspendObjectClock(Canonical);
|
|
|
|
internal void ResetObjectClockForEnterWorld(bool isStatic) =>
|
|
_directory.ResetObjectClockForEnterWorld(Canonical, isStatic);
|
|
|
|
internal RetailPhysicsStateTransition ApplyRawPhysicsState(uint rawState) =>
|
|
_directory.ApplyRawPhysicsState(Canonical, rawState);
|
|
|
|
internal void NoteObjDescProjectionSynchronization()
|
|
{
|
|
AppearanceProjectionSynchronizationPending = true;
|
|
if (AppearanceHydrationInProgress)
|
|
AppearanceHydrationRetryRequested = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cell used when a streamed landblock asks live objects to restore their
|
|
/// spatial projection. Once materialized, the canonical runtime cell wins;
|
|
/// the retained server snapshot is only a first-materialization source.
|
|
/// </summary>
|
|
public uint ProjectionCellId => WorldEntity is not null
|
|
? FullCellId
|
|
: Snapshot.Position?.LandblockId ?? FullCellId;
|
|
|
|
internal void RefreshDerivedState(bool refreshPosition = true) =>
|
|
_directory.RefreshSnapshot(Canonical, Snapshot, refreshPosition);
|
|
}
|
|
|
|
public readonly record struct LiveEntityRegistrationResult(
|
|
InboundCreateResult Inbound,
|
|
RuntimeEntityRecord? Canonical,
|
|
LiveEntityRecord? Projection,
|
|
bool LogicalRegistrationCreated,
|
|
bool ReplacedExistingGeneration,
|
|
Exception? PriorGenerationCleanupFailure = null);
|
|
|
|
/// <summary>
|
|
/// Update-thread App projection/lifecycle host over the canonical
|
|
/// <see cref="RuntimeEntityDirectory"/>. Runtime owns server GUIDs,
|
|
/// incarnations, accepted snapshots, timestamps, tombstones, and local IDs;
|
|
/// this host owns graphical components and retryable projection teardown.
|
|
/// <see cref="GpuWorldState"/> is a spatial projection only; moving between
|
|
/// loaded and pending buckets never replays a create-time renderer, script,
|
|
/// animation, or effect action.
|
|
///
|
|
/// <para>
|
|
/// Retail keeps one object per accepted INSTANCE_TS: same-generation
|
|
/// CreateObject branches mutate it, Pickup leaves world without destruction,
|
|
/// and Delete/instance replacement end it. See SmartBox CreateObject/event
|
|
/// pseudocode in <c>2026-07-13-retail-projectile-vfx-pseudocode.md</c>,
|
|
/// <c>CPhysicsObj::set_description</c> (0x00514F40),
|
|
/// <c>CPhysicsObj::change_cell</c> (0x00513390), and
|
|
/// <c>CPhysicsObj::exit_world</c> (0x00514E60).
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|
{
|
|
public const uint FirstLiveEntityId = RuntimeEntityDirectory.FirstLocalEntityId;
|
|
public const uint LastLiveEntityId = RuntimeEntityDirectory.LastLocalEntityId;
|
|
|
|
private readonly GpuWorldState _spatial;
|
|
private readonly ILiveEntityResourceLifecycle _resources;
|
|
private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle;
|
|
private readonly RuntimeEntityObjectLifetime _entityObjects;
|
|
private readonly RuntimeEntityDirectory _directory;
|
|
private readonly RuntimePhysicsState _physics;
|
|
private readonly LiveEntityProjectionStore _projections;
|
|
// Logical components survive retail's 25-second leave-visibility lifetime.
|
|
// Frame loops must not scan those retained owners. These component-specific
|
|
// indexes contain only records with a loaded, non-cellless spatial
|
|
// projection. Hidden and Frozen remain members: their state controls what
|
|
// each retail update path advances after the O(active) lookup.
|
|
private readonly Dictionary<RuntimeEntityKey, ILiveEntityAnimationRuntime> _spatialAnimations = new();
|
|
private readonly List<RuntimeEntityRecord> _spatialRootCanonicalScratch = new();
|
|
private readonly List<RuntimeEntityRecord> _spatialRemoteCanonicalScratch = new();
|
|
// Runtime placement receipts are presentation observations of an already
|
|
// committed canonical SetPosition transaction. GpuWorldState still emits
|
|
// its ordinary visibility callback while that observation rebuckets or
|
|
// withdraws a sidecar, so pin the exact incarnation while the callback is
|
|
// in flight. A depth (rather than a bool) preserves nested/re-entrant
|
|
// projection mutations without ever turning graphical visibility into a
|
|
// Runtime physics/workset mutation.
|
|
private readonly Dictionary<RuntimeEntityKey, int>
|
|
_presentationOnlySpatialMutationDepth = new();
|
|
private bool _isClearing;
|
|
private bool _sessionClearPendingFinalization;
|
|
private bool _isRegisteringResources;
|
|
private int _logicalTeardownDepth;
|
|
private uint _rebucketingGuid;
|
|
|
|
public LiveEntityRuntime(
|
|
GpuWorldState spatial,
|
|
ILiveEntityResourceLifecycle resources,
|
|
RuntimeEntityObjectLifetime entityObjects)
|
|
: this(
|
|
spatial,
|
|
resources,
|
|
NullLiveEntityRuntimeComponentLifecycle.Instance,
|
|
entityObjects)
|
|
{
|
|
}
|
|
|
|
public LiveEntityRuntime(
|
|
GpuWorldState spatial,
|
|
ILiveEntityResourceLifecycle resources,
|
|
Action<LiveEntityRecord> tearDownRuntimeComponents,
|
|
RuntimeEntityObjectLifetime entityObjects)
|
|
: this(
|
|
spatial,
|
|
resources,
|
|
new DelegateLiveEntityRuntimeComponentLifecycle(tearDownRuntimeComponents),
|
|
entityObjects)
|
|
{
|
|
}
|
|
|
|
internal LiveEntityRuntime(
|
|
GpuWorldState spatial,
|
|
ILiveEntityResourceLifecycle resources,
|
|
ILiveEntityRuntimeComponentLifecycle? runtimeComponentLifecycle,
|
|
RuntimeEntityObjectLifetime entityObjects)
|
|
{
|
|
_spatial = spatial ?? throw new ArgumentNullException(nameof(spatial));
|
|
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
|
|
_runtimeComponentLifecycle = runtimeComponentLifecycle
|
|
?? throw new ArgumentNullException(nameof(runtimeComponentLifecycle));
|
|
_entityObjects = entityObjects
|
|
?? throw new ArgumentNullException(nameof(entityObjects));
|
|
_directory = _entityObjects.Entities;
|
|
_physics = _entityObjects.Physics;
|
|
_projections = new LiveEntityProjectionStore(_directory);
|
|
_spatial.LiveProjectionVisibilityChanged += OnSpatialVisibilityChanged;
|
|
_physics.CellCommitted += OnRuntimePhysicsCellCommitted;
|
|
}
|
|
|
|
public int Count => _directory.Count;
|
|
public int PendingTeardownCount => _directory.PendingTeardownCount;
|
|
public int MaterializedCount => _projections.MaterializedCount;
|
|
public IReadOnlyCollection<LiveEntityRecord> Records => _projections.ActiveRecords;
|
|
public IReadOnlyCollection<LiveEntityRecord> MaterializedRecords =>
|
|
_projections.MaterializedRecords;
|
|
public IReadOnlyCollection<LiveEntityRecord> VisibleRecords =>
|
|
_projections.VisibleRecords;
|
|
internal IReadOnlyCollection<RuntimeEntityRecord> CanonicalRecords =>
|
|
_directory.ActiveRecords;
|
|
internal ulong SessionLifetimeVersion => _directory.SessionLifetimeVersion;
|
|
internal RuntimePhysicsState Physics => _physics;
|
|
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _directory.Snapshots;
|
|
internal int AnimationRuntimeCount
|
|
{
|
|
get
|
|
{
|
|
int count = 0;
|
|
IReadOnlyList<LiveEntityRecord> records = _projections.ActiveRecords;
|
|
for (int i = 0; i < records.Count; i++)
|
|
{
|
|
if (records[i].AnimationRuntime is not null)
|
|
count++;
|
|
}
|
|
return count;
|
|
}
|
|
}
|
|
internal int SpatialAnimationRuntimeCount => _spatialAnimations.Count;
|
|
internal int SpatialRemoteMotionRuntimeCount => _physics.SpatialRemoteCount;
|
|
internal int SpatialProjectileRuntimeCount => _physics.SpatialProjectileCount;
|
|
internal int SpatialRootObjectCount => _physics.SpatialRootCount;
|
|
public ParentAttachmentState ParentAttachments => _directory.ParentAttachments;
|
|
|
|
internal bool TryGetCanonical(
|
|
uint serverGuid,
|
|
out RuntimeEntityRecord canonical) =>
|
|
_directory.TryGetActive(serverGuid, out canonical);
|
|
|
|
internal bool TryGetProjection(
|
|
RuntimeEntityRecord canonical,
|
|
out LiveEntityRecord record) =>
|
|
_projections.TryGet(canonical, out record);
|
|
|
|
internal bool IsCurrentCanonical(RuntimeEntityRecord canonical) =>
|
|
_directory.IsCurrent(canonical);
|
|
|
|
internal bool IsCurrentCreateIntegration(
|
|
RuntimeEntityRecord canonical,
|
|
ulong expectedCreateIntegrationVersion) =>
|
|
_directory.IsCurrent(canonical)
|
|
&& canonical.CreateIntegrationVersion == expectedCreateIntegrationVersion;
|
|
|
|
internal void SetHasPartArray(
|
|
RuntimeEntityRecord canonical,
|
|
bool value) =>
|
|
_directory.SetHasPartArray(canonical, value);
|
|
|
|
/// <summary>
|
|
/// Raised after a materialized projection enters or leaves the currently
|
|
/// loaded spatial view. Logical resources remain owned by the record.
|
|
/// </summary>
|
|
public event Action<LiveEntityRecord, bool>? ProjectionVisibilityChanged;
|
|
|
|
public LiveEntityRegistrationResult RegisterLiveEntity(
|
|
WorldSession.EntitySpawn incoming,
|
|
bool isLocalPlayer = false)
|
|
{
|
|
if (_isClearing || _sessionClearPendingFinalization || _isRegisteringResources)
|
|
{
|
|
throw new InvalidOperationException(
|
|
_isClearing || _sessionClearPendingFinalization
|
|
? "A live entity cannot register while the session lifetime is clearing."
|
|
: "A live entity cannot register from inside atomic resource registration.");
|
|
}
|
|
|
|
// C3c route-1 flip: every graphical initial Create enters the
|
|
// canonical initial-residence lease. The accepted wire frame stays on
|
|
// the canonical record with FullCell 0 until the authored Runtime
|
|
// SetPosition operation commits; the host's first-entry drive
|
|
// controller walks the conductors from the residence-begin
|
|
// notification.
|
|
RuntimeEntityRegistrationResult registration =
|
|
_entityObjects.RegisterEntityWithInitialResidence(
|
|
incoming,
|
|
isLocalPlayer,
|
|
RetirePriorProjection);
|
|
RuntimeEntityRecord? canonical = registration.Canonical;
|
|
LiveEntityRecord? projection = canonical is null
|
|
? null
|
|
: _projections.GetCurrentOrDefault(canonical.ServerGuid);
|
|
if (projection is not null
|
|
&& ReferenceEquals(projection.Canonical, canonical))
|
|
{
|
|
RefreshSpatialRuntimeIndexes(projection);
|
|
}
|
|
return new LiveEntityRegistrationResult(
|
|
registration.Inbound,
|
|
canonical,
|
|
projection,
|
|
registration.LogicalRegistrationCreated,
|
|
registration.ReplacedExistingGeneration,
|
|
registration.PriorGenerationCleanupFailure);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Completes logical registration when a renderable projection can be
|
|
/// hydrated. The factory is called at most once for the incarnation.
|
|
/// </summary>
|
|
public WorldEntity? MaterializeLiveEntity(
|
|
uint serverGuid,
|
|
uint fullCellId,
|
|
Func<uint, WorldEntity> factory,
|
|
LiveEntityProjectionKind projectionKind = LiveEntityProjectionKind.World)
|
|
{
|
|
if (!_directory.TryGetActive(
|
|
serverGuid,
|
|
out RuntimeEntityRecord canonical))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return MaterializeLiveEntity(
|
|
canonical,
|
|
fullCellId,
|
|
factory,
|
|
projectionKind,
|
|
initializeProjection: null,
|
|
out _);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Claims the canonical Runtime local ID, creates its exact-key App
|
|
/// sidecar, and synchronously constructs the projection. No sidecar exists
|
|
/// before this method crosses the local-ID claim boundary.
|
|
/// </summary>
|
|
internal WorldEntity? MaterializeLiveEntity(
|
|
RuntimeEntityRecord expectedCanonical,
|
|
uint fullCellId,
|
|
Func<uint, WorldEntity> factory,
|
|
LiveEntityProjectionKind projectionKind,
|
|
Action<LiveEntityRecord>? initializeProjection,
|
|
out LiveEntityRecord? record,
|
|
LiveEntityMaterializationResidence residence =
|
|
LiveEntityMaterializationResidence.LegacyImmediate)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedCanonical);
|
|
ArgumentNullException.ThrowIfNull(factory);
|
|
if (residence is LiveEntityMaterializationResidence
|
|
.AwaitRuntimePlacement
|
|
&& projectionKind is not LiveEntityProjectionKind.World)
|
|
{
|
|
throw new ArgumentException(
|
|
"Only a top-level world projection can await canonical Runtime placement.",
|
|
nameof(projectionKind));
|
|
}
|
|
record = null;
|
|
if (_isClearing
|
|
|| _sessionClearPendingFinalization
|
|
|| _logicalTeardownDepth != 0
|
|
|| _isRegisteringResources)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"A live entity cannot materialize inside an active logical-lifetime transition.");
|
|
}
|
|
if (!_directory.IsCurrent(expectedCanonical))
|
|
return null;
|
|
|
|
uint serverGuid = expectedCanonical.ServerGuid;
|
|
bool createdSidecar = false;
|
|
if (!_projections.TryGet(expectedCanonical, out record))
|
|
{
|
|
_ = expectedCanonical.LocalEntityId
|
|
?? throw new InvalidOperationException(
|
|
"Runtime must issue entity identity before App projection.");
|
|
try
|
|
{
|
|
record = _projections.AddMaterializing(expectedCanonical);
|
|
createdSidecar = true;
|
|
record.MaterializationResidence = residence;
|
|
initializeProjection?.Invoke(record);
|
|
}
|
|
catch
|
|
{
|
|
if (record is not null)
|
|
{
|
|
_projections.RemoveActive(record);
|
|
record.ProjectionKey = null;
|
|
record = null;
|
|
}
|
|
throw;
|
|
}
|
|
}
|
|
else if (record.MaterializationResidence != residence)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8}/{expectedCanonical.Incarnation} "
|
|
+ $"cannot change its materialization residence from "
|
|
+ $"{record.MaterializationResidence} to {residence}.");
|
|
}
|
|
|
|
if (record.WorldEntity is null)
|
|
{
|
|
uint localId = expectedCanonical.LocalEntityId
|
|
?? throw new InvalidOperationException(
|
|
"A materializing App sidecar lost its Runtime local ID.");
|
|
WorldEntity entity;
|
|
try
|
|
{
|
|
entity = factory(localId)
|
|
?? throw new InvalidOperationException(
|
|
"A live-entity projection factory returned null.");
|
|
if (entity.Id != localId)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live projection id 0x{entity.Id:X8} did not match reserved id 0x{localId:X8}.");
|
|
}
|
|
if (entity.ServerGuid != serverGuid)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live projection guid 0x{entity.ServerGuid:X8} did not match record 0x{serverGuid:X8}.");
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
if (createdSidecar)
|
|
{
|
|
_projections.RemoveActive(record);
|
|
record.ProjectionKey = null;
|
|
record = null;
|
|
}
|
|
throw;
|
|
}
|
|
|
|
record.WorldEntity = entity;
|
|
// Runtime-placement residence is sticky for this incarnation and
|
|
// must suppress draw before arbitrary resource callbacks observe
|
|
// or re-enter the sidecar.
|
|
RefreshPresentation(record);
|
|
_isRegisteringResources = true;
|
|
try
|
|
{
|
|
// Registration may span several App owners. Until the whole
|
|
// edge returns, Unregister remains a required rollback
|
|
// obligation; a failed rollback retains this identity as a
|
|
// teardown tombstone instead of making partial owners
|
|
// unreachable.
|
|
record.ResourcesRegistered = true;
|
|
try
|
|
{
|
|
_resources.Register(entity);
|
|
}
|
|
catch (Exception registrationError)
|
|
{
|
|
Exception? rollbackError = null;
|
|
try
|
|
{
|
|
_resources.Unregister(entity);
|
|
record.ResourcesRegistered = false;
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
rollbackError = error;
|
|
}
|
|
|
|
if (rollbackError is null)
|
|
{
|
|
_projections.RemoveActive(record);
|
|
record.ProjectionKey = null;
|
|
record.WorldEntity = null;
|
|
record = null;
|
|
throw;
|
|
}
|
|
|
|
_entityObjects.RetireAfterProjectionAcquisitionFailure(
|
|
expectedCanonical);
|
|
if (_projections.RemoveActive(record))
|
|
{
|
|
RetainTeardownRecord(record);
|
|
}
|
|
throw new AggregateException(
|
|
"Live entity resource registration and rollback both failed; " +
|
|
"the partial owner was retained for teardown retry.",
|
|
registrationError,
|
|
rollbackError);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_isRegisteringResources = false;
|
|
}
|
|
}
|
|
|
|
if (!_directory.IsCurrent(expectedCanonical)
|
|
|| !_projections.TryGet(expectedCanonical, out LiveEntityRecord currentRecord)
|
|
|| !ReferenceEquals(currentRecord, record))
|
|
{
|
|
record = null;
|
|
return null;
|
|
}
|
|
|
|
record.ProjectionKind = projectionKind;
|
|
if (projectionKind is LiveEntityProjectionKind.World)
|
|
{
|
|
// Attached projections inherit the complete ancestor visibility
|
|
// chain. Once the same logical entity becomes a top-level world
|
|
// projection it has no render ancestor, so retained inherited
|
|
// suppression must be cleared at this ownership transition.
|
|
record.WorldEntity!.IsAncestorDrawVisible = true;
|
|
}
|
|
RefreshPresentation(record);
|
|
WorldEntity materialized = record.WorldEntity!;
|
|
if (residence is LiveEntityMaterializationResidence
|
|
.AwaitRuntimePlacement)
|
|
{
|
|
// Resource construction is a logical App-side ownership edge, not
|
|
// world residence. Runtime's immutable Place receipt is the only
|
|
// path which may install the accepted frame and spatial bucket.
|
|
record.IsSpatiallyProjected = false;
|
|
record.IsSpatiallyVisible = false;
|
|
RefreshSpatialPresentationIndexes(record);
|
|
RefreshPresentation(record);
|
|
return materialized;
|
|
}
|
|
if (!RebucketLiveEntity(serverGuid, fullCellId)
|
|
|| !_projections.TryGet(expectedCanonical, out LiveEntityRecord? current)
|
|
|| !ReferenceEquals(current, record)
|
|
|| !ReferenceEquals(current.WorldEntity, materialized))
|
|
{
|
|
// Visibility observers may replace this GUID while rebucketing.
|
|
// A failed teardown can retain the displaced entity as a retry
|
|
// tombstone; it is not the result of this materialization.
|
|
record = null;
|
|
return null;
|
|
}
|
|
return materialized;
|
|
}
|
|
|
|
/// <summary>Changes spatial buckets only. No logical resource callbacks run.</summary>
|
|
public bool RebucketLiveEntity(uint serverGuid, uint spatialCellOrLandblockId)
|
|
{
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is not { } entity)
|
|
return false;
|
|
if (record.MaterializationResidence is
|
|
LiveEntityMaterializationResidence.AwaitRuntimePlacement
|
|
&& HasActiveInitialCreateResidence(record.Canonical))
|
|
{
|
|
// C3c: while the initial-create residence is ACTIVE, Runtime's
|
|
// SetPosition owner is the sole canonical position/cell/
|
|
// object-clock authority and even the graphical bucket stays
|
|
// suppressed: the conductor's completion receipt (which reaches
|
|
// presentation through
|
|
// TryApplyInitialCreateCompletionPresentation, not this API) is
|
|
// the entity's first world-visible moment. Without this gate a
|
|
// re-entrant caller (e.g. a resource-registration observer)
|
|
// could install a bucket for a suppressed record before its
|
|
// placement ever committed. A STALE residence is lazily retired
|
|
// by this same query, after which legacy moves flow.
|
|
//
|
|
// C3c-R1 review R2: the gate is the EXACT-token residence
|
|
// activity view, never the sticky MaterializationResidence enum
|
|
// alone. Post-residence (the lease completed and was consumed)
|
|
// this method falls through to the FULL legacy branch below:
|
|
// the unflipped update routes (network position/state, remote
|
|
// and local teleports, streaming reprojection, hydration
|
|
// recovery) are the position authority again, and retail's
|
|
// prepare_to_enter_world (0x00511FA0) clock rebase must run on
|
|
// every root-workset membership edge — the earlier
|
|
// presentation-only shortcut skipped CommitRebucket and that
|
|
// clock edge for the entity's whole post-residence lifetime.
|
|
return false;
|
|
}
|
|
|
|
RuntimeEntityKey key = RequireProjectionKey(record);
|
|
bool wasProjected = record.IsSpatiallyProjected;
|
|
bool wasVisible = record.IsSpatiallyVisible;
|
|
bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical);
|
|
ulong projectionOperation = ++record.ProjectionMutationVersion;
|
|
// GpuWorldState reports an intermediate false/true pair while moving
|
|
// between two loaded buckets. Suppress those implementation details
|
|
// and publish only the final logical visibility edge.
|
|
record.IsSpatiallyProjected = true;
|
|
bool hasExactDestinationCell = spatialCellOrLandblockId != 0u
|
|
&& (spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu;
|
|
if (hasExactDestinationCell)
|
|
{
|
|
// Runtime's physics cell commit and the graphical sidecar are one
|
|
// SetPosition result. Retail CPhysicsObj::set_cell changes the
|
|
// CObjCell read by ShouldDrawParticles at the same edge; retaining
|
|
// the prior sidecar cell makes newly-created spell particles fail
|
|
// IsInView as soon as the player crosses an outdoor landcell.
|
|
// #282: a live entity's cell is ParentCellId alone. EffectCellId
|
|
// exists only for outdoor dat stabs/building shells, which keep a
|
|
// null render parent (LandblockLoader:80,97) - writing it here
|
|
// made it win WorldEntity.VisibilityCellId for live entities,
|
|
// which the 11 non-rebucketing per-tick writers cannot maintain.
|
|
entity.ParentCellId = spatialCellOrLandblockId;
|
|
}
|
|
Exception? spatialNotificationFailure = null;
|
|
uint priorRebucketingGuid = _rebucketingGuid;
|
|
_rebucketingGuid = serverGuid;
|
|
try
|
|
{
|
|
try
|
|
{
|
|
_spatial.RebucketLiveEntity(
|
|
key,
|
|
entity,
|
|
spatialCellOrLandblockId);
|
|
}
|
|
catch (AggregateException error)
|
|
{
|
|
// GpuWorldState has already committed the bucket move and
|
|
// drained every visibility observer before reporting their
|
|
// failures. Finish this runtime transaction before surfacing
|
|
// the notification error to the caller.
|
|
spatialNotificationFailure = error;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_rebucketingGuid = priorRebucketingGuid;
|
|
}
|
|
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure: null);
|
|
return false;
|
|
}
|
|
bool visible = _spatial.IsLiveEntityProjectionResident(key);
|
|
record.IsSpatiallyVisible = visible;
|
|
RefreshPresentation(record);
|
|
uint committedFullCell =
|
|
(spatialCellOrLandblockId & 0xFFFFu) != 0xFFFFu
|
|
? spatialCellOrLandblockId
|
|
: record.FullCellId;
|
|
uint committedLandblock = spatialCellOrLandblockId == 0
|
|
? 0u
|
|
: (spatialCellOrLandblockId & 0xFFFF0000u) | 0xFFFFu;
|
|
if (!_entityObjects.CommitRebucket(
|
|
record.Canonical,
|
|
committedFullCell,
|
|
committedLandblock))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure: null);
|
|
return false;
|
|
}
|
|
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
|
|
&& record.IsSpatiallyProjected
|
|
&& record.IsSpatiallyVisible
|
|
&& record.FullCellId != 0;
|
|
// prepare_to_enter_world (0x00511FA0) writes update_time = cur_time.
|
|
// Only a root-workset membership edge rebases the clock. Ordinary
|
|
// loaded-to-loaded movement preserves it; parented/attached objects
|
|
// take retail update_object's parent early-out and remain suspended.
|
|
if (!wasProjected && !isOrdinaryRoot)
|
|
{
|
|
record.SuspendObjectClock();
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
}
|
|
else if (wasOrdinaryRoot != isOrdinaryRoot)
|
|
{
|
|
if (isOrdinaryRoot)
|
|
{
|
|
// InitObjectBegin and every leave-visibility edge are inactive
|
|
// before retail prepare_to_enter_world. Preserve that
|
|
// prerequisite for a first materialization as well.
|
|
if (!wasProjected)
|
|
record.ObjectClock.Deactivate();
|
|
record.ResetObjectClockForEnterWorld(
|
|
(record.FinalPhysicsState & PhysicsStateFlags.Static) != 0);
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
}
|
|
else
|
|
{
|
|
record.SuspendObjectClock();
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
}
|
|
}
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
Exception? runtimeNotificationFailure = null;
|
|
if (!wasProjected || wasVisible != visible)
|
|
{
|
|
try
|
|
{
|
|
PublishProjectionVisibilityChanged(record, visible);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
runtimeNotificationFailure = error;
|
|
}
|
|
}
|
|
// Final observers are intentionally re-entrant. A callback can delete
|
|
// and recreate this GUID or start a newer rebucket on the same record;
|
|
// the outer caller must not continue with captures from that displaced
|
|
// projection operation.
|
|
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure);
|
|
return false;
|
|
}
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// C3c: the graphical-bucket-only projection of a conductor-owned
|
|
/// initial placement — called from
|
|
/// <see cref="TryApplyInitialCreateCompletionPresentation"/> (the
|
|
/// completion receipt at the initial-create residence boundary) and,
|
|
/// since C4 route 7 D4, from <see cref="RebucketEquippedChildPresentation"/>
|
|
/// (an attached child, whose canonical cell Runtime's D1/D2 propagation
|
|
/// already owns). Never from the public <see cref="RebucketLiveEntity"/>
|
|
/// (C3c-R1 review R2: post-residence moves take the full legacy branch
|
|
/// there). Deliberately never calls <c>CommitRebucket</c>,
|
|
/// <c>SuspendObjectClock</c>, or <c>ResetObjectClockForEnterWorld</c> —
|
|
/// Runtime's SetPosition commit already owns all of those for the
|
|
/// residence-driven placement this receipt projects. May place into a
|
|
/// pending (not-yet-loaded) bucket exactly like the legacy Create path
|
|
/// did; the pending drain publishes visibility when the landblock loads.
|
|
/// </summary>
|
|
private bool RebucketLiveEntityPresentationOnly(
|
|
uint serverGuid,
|
|
LiveEntityRecord record,
|
|
WorldEntity entity,
|
|
uint spatialCellOrLandblockId)
|
|
{
|
|
RuntimeEntityKey key = RequireProjectionKey(record);
|
|
bool wasProjected = record.IsSpatiallyProjected;
|
|
bool wasVisible = record.IsSpatiallyVisible;
|
|
ulong projectionOperation = ++record.ProjectionMutationVersion;
|
|
record.IsSpatiallyProjected = true;
|
|
Exception? spatialNotificationFailure = null;
|
|
uint priorRebucketingGuid = _rebucketingGuid;
|
|
_rebucketingGuid = serverGuid;
|
|
BeginPresentationOnlySpatialMutation(key);
|
|
try
|
|
{
|
|
try
|
|
{
|
|
_spatial.RebucketLiveEntity(
|
|
key,
|
|
entity,
|
|
spatialCellOrLandblockId);
|
|
}
|
|
catch (AggregateException error)
|
|
{
|
|
spatialNotificationFailure = error;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
EndPresentationOnlySpatialMutation(key);
|
|
_rebucketingGuid = priorRebucketingGuid;
|
|
}
|
|
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure: null);
|
|
return false;
|
|
}
|
|
bool visible = _spatial.IsLiveEntityProjectionResident(key);
|
|
record.IsSpatiallyVisible = visible;
|
|
RefreshSpatialPresentationIndexes(record);
|
|
RefreshPresentation(record);
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
Exception? runtimeNotificationFailure = null;
|
|
if (!wasProjected || wasVisible != visible)
|
|
{
|
|
try
|
|
{
|
|
PublishProjectionVisibilityChanged(record, visible);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
runtimeNotificationFailure = error;
|
|
}
|
|
}
|
|
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure);
|
|
return false;
|
|
}
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// C4 route 7 D4: the equipped-child render-tick presentation-only
|
|
/// rebucket. Runtime's D1/D2 propagation
|
|
/// (<see cref="RuntimeEntityDirectory.SetFullCell"/>) is now the
|
|
/// canonical writer for a committed child's cell — the render tick only
|
|
/// needs to move the DRAW bucket to match. Deliberately the same C3c
|
|
/// shape as <see cref="RebucketLiveEntityPresentationOnly"/>: no
|
|
/// <c>CommitRebucket</c>, no clock edges, because a committed child's
|
|
/// <c>ObjectClock</c> stays suspended for its whole attached lifetime
|
|
/// (retail <c>update_object</c>'s <c>parent != 0</c> early-out,
|
|
/// @0x00515D40) and Runtime already owns the canonical spatial
|
|
/// authority. Guarded to a record with a currently COMMITTED parent
|
|
/// relation, AND (review A9) the same active-initial-create-residence
|
|
/// gate <see cref="RebucketLiveEntity"/> itself honours, so this can
|
|
/// never become a general bypass of the legacy branch for an ordinary
|
|
/// root entity. Returns a typed disposition rather than a bare bool
|
|
/// (review A2/R3) — <see cref="EquippedChildPresentationRebucketDisposition.NotAttached"/>
|
|
/// is the KNOWN-benign case (the Runtime relation already unwound while
|
|
/// <c>_attachedByChild</c> still holds the child, ahead of
|
|
/// <c>OnChildBecameUnparented</c>'s own teardown); the caller must not
|
|
/// discard the result.
|
|
///
|
|
/// <para>
|
|
/// R11 (retail-conformance review, LOW; DOWNGRADED at the round-3
|
|
/// review, N7 — an earlier draft of this note overstated "proven"):
|
|
/// unlike the legacy <see cref="RebucketLiveEntity"/>, this path never
|
|
/// calls <c>SynchronizePhysicsBodyActiveState</c>. VERY LIKELY inert,
|
|
/// not fully proven: a committed child's <c>Snapshot.Position</c> is
|
|
/// always null (contract §0 item 10 / P1), and two of the production
|
|
/// <c>PhysicsBody</c> constructors reachable from a live projection
|
|
/// (<c>DatLiveEntityProjectionMaterializer</c>'s static scheduler,
|
|
/// <c>ProjectileController</c>) both require a non-null
|
|
/// <c>spawn.Position</c> before calling <c>GetOrCreatePhysicsBody</c>,
|
|
/// ruling them out for a committed child. A THIRD constructor —
|
|
/// <c>RuntimeRemoteFirstEntryState.cs:425</c>, the remote first-entry
|
|
/// conductor's own body construction for an entity's OWN
|
|
/// initial-create residence — is NOT ruled out with the same rigor: it
|
|
/// does not gate on <c>spawn.Position</c>, and a committed child CAN
|
|
/// hold an initial-create residence lease during route 7's dormant-
|
|
/// residence deferral window (invariant 9). The argument for inertness
|
|
/// there is that <c>CommitAcceptedParentCellless</c> calls
|
|
/// <c>ForgetInitialCreateResidence</c> before this method's caller
|
|
/// (<c>TickChild</c>) can ever run — by the time a child has an
|
|
/// <c>AttachedChild</c> entry at all, its residence should already be
|
|
/// cancelled — but this is NOT independently traced end-to-end against
|
|
/// <see cref="RuntimeRemoteFirstEntryState"/>'s own state machine, and
|
|
/// <c>SuspendObjectClock</c> does not itself synchronize a body's
|
|
/// Active transient bit, so IF a body existed in that window the
|
|
/// dropped call would not be a no-op. Flagged honestly as unverified
|
|
/// rather than asserted; the correct fix if this is ever found reachable
|
|
/// is to add the call back or route the synchronization through the
|
|
/// residence-cancellation edge that already runs first.
|
|
/// </para>
|
|
/// </summary>
|
|
internal EquippedChildPresentationRebucketDisposition RebucketEquippedChildPresentation(
|
|
uint serverGuid,
|
|
uint parentCellId)
|
|
{
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is not { } entity)
|
|
{
|
|
return EquippedChildPresentationRebucketDisposition.NoProjection;
|
|
}
|
|
if (!_directory.ParentAttachments.HasCommittedParent(serverGuid))
|
|
return EquippedChildPresentationRebucketDisposition.NotAttached;
|
|
if (record.MaterializationResidence is
|
|
LiveEntityMaterializationResidence.AwaitRuntimePlacement
|
|
&& HasActiveInitialCreateResidence(record.Canonical))
|
|
{
|
|
return EquippedChildPresentationRebucketDisposition.NotAttached;
|
|
}
|
|
// B6 (round-3 review): a `false` here ALWAYS means the projection
|
|
// operation was superseded mid-flight (RebucketLiveEntityPresentationOnly's
|
|
// only `return false` sites are both guarded by
|
|
// `!IsCurrentProjectionOperation`) — the record was current when
|
|
// this method started, so this is Displaced, never NoProjection.
|
|
return RebucketLiveEntityPresentationOnly(
|
|
serverGuid,
|
|
record,
|
|
entity,
|
|
parentCellId)
|
|
? EquippedChildPresentationRebucketDisposition.Moved
|
|
: EquippedChildPresentationRebucketDisposition.Displaced;
|
|
}
|
|
|
|
/// <summary>
|
|
/// C3c: applies one initial-Create ExecutorCompleted receipt's
|
|
/// presentation — the graphical binding point for a residence-driven
|
|
/// initial placement. Runtime committed position, cell, body, clocks,
|
|
/// and worksets during the conductor's drain; this installs the
|
|
/// committed frame on the sidecar and moves its graphical bucket
|
|
/// (pending buckets allowed — the legacy Create path's own semantics).
|
|
/// Superseded facts (a later legacy-path move already advanced the
|
|
/// record past the receipt) are treated as already-projected: the
|
|
/// receipt is stale for presentation and must not snap the entity back.
|
|
/// </summary>
|
|
internal bool TryApplyInitialCreateCompletionPresentation(
|
|
in RuntimePlacementProjectionSnapshot projection)
|
|
{
|
|
RuntimePlacementProjectionToken token = projection.Token;
|
|
if (!token.IsValid
|
|
|| token.SessionLifetimeVersion != _directory.SessionLifetimeVersion
|
|
|| !_projections.TryGet(token.Entity, out LiveEntityRecord? record)
|
|
|| !_directory.IsCurrent(record.Canonical)
|
|
|| record.Canonical.Key != token.Entity
|
|
|| record.WorldEntity is not { } entity)
|
|
{
|
|
// No sidecar (a deferred-child replay materializes later and
|
|
// self-projects from canonical state) or a displaced identity —
|
|
// acknowledge-only.
|
|
return true;
|
|
}
|
|
if (record.FullCellId != token.ExactCellId
|
|
|| record.Canonical.PlacementCommitVersion
|
|
!= token.PlacementCommitVersion)
|
|
{
|
|
// A newer move superseded this receipt's facts after the drain.
|
|
//
|
|
// WHY THESE TWO TERMS AND NOT `PositionAuthorityVersion` (C5b
|
|
// review, finding D2 — examined and deliberately left alone;
|
|
// the sibling guard in
|
|
// TryGetRuntimePlacementProjectionRecord(requirePlacementVersions:
|
|
// true) DOES check it, and the asymmetry is intentional).
|
|
//
|
|
// This receipt's facts are the CANONICAL BODY's pose and cell at
|
|
// publish time (RuntimeSetPositionState.PublishExecutorCompletion
|
|
// builds the snapshot from `record.PhysicsBody` and
|
|
// `record.FullCellId`). There are exactly two owners that can move
|
|
// either: a Runtime SetPosition commit/withdrawal, every one of
|
|
// which calls `AdvancePlacementCommit` — the ONLY caller family is
|
|
// RuntimeSetPositionState itself — and a rebucket, which moves
|
|
// `FullCellId`. Both are covered above.
|
|
//
|
|
// An intervening accepted steady-state Position is NOT one of
|
|
// them, and specifically was not turned into one by C5b (#275).
|
|
// C5b made the merge withhold the `FullCellId` write, but the
|
|
// merge never wrote the canonical body either: it refreshes the
|
|
// snapshot and advances `PositionAuthorityVersion`, and the
|
|
// App-side generic tail writes the RENDER entity, not the body.
|
|
// The wire-cell half is still covered here because the OnPosition
|
|
// prologue rebucket (W2) commits it in the same call, so a
|
|
// cell-changing Position still trips the first term; the paths
|
|
// that return before W2 (the local force arm, the missile arm)
|
|
// leave the record exactly where the last commit put it, which is
|
|
// this receipt's own cell — correctly NOT a supersession.
|
|
//
|
|
// Adding `PositionAuthorityVersion` would therefore decline
|
|
// receipts whose facts are still true, on the entity's FIRST
|
|
// world-visible moment: the pose write and
|
|
// RebucketLiveEntityPresentationOnly below would be skipped while
|
|
// TryPublishPlace still publishes, so a packet that returned
|
|
// before the generic tail's render write would leave the sidecar
|
|
// visible at its materialized pose in a possibly-wrong bucket.
|
|
// That is a behaviour change in the wrong direction, not a
|
|
// restoration.
|
|
//
|
|
// The ONE supersession neither term covers is
|
|
// RuntimeRemotePlacementDriveController.StoreAcceptedDestinationPose:
|
|
// the far-snap Refused/Contention arm writes `body.Position` and
|
|
// `body.Orientation` with no placement commit and no cell move, so
|
|
// it can stale this receipt's pose silently. That is pre-existing
|
|
// (it predates C5b, which changed nothing about that arm) and is
|
|
// filed as docs/ISSUES.md #323 rather than papered over here.
|
|
return true;
|
|
}
|
|
|
|
entity.SetPosition(projection.WorldPosition);
|
|
entity.Rotation = projection.Orientation;
|
|
// #282: live entities carry ParentCellId only; EffectCellId is the
|
|
// outdoor dat stab / building-shell field (LandblockLoader:80,97).
|
|
entity.ParentCellId = token.ExactCellId;
|
|
return RebucketLiveEntityPresentationOnly(
|
|
record.ServerGuid,
|
|
record,
|
|
entity,
|
|
token.ExactCellId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// C3c: true while the exact incarnation behind <paramref name="key"/>
|
|
/// holds an initial-create residence lease — the discriminator the
|
|
/// placement sink uses to leave conductor-owned Place/Withdraw receipts
|
|
/// at the FIFO head for the drive controller to consume.
|
|
/// </summary>
|
|
internal bool HasActiveInitialCreateResidence(RuntimeEntityKey key) =>
|
|
_directory.TryGetByLocalId(
|
|
key.LocalEntityId,
|
|
out RuntimeEntityRecord canonical)
|
|
&& _directory.IsCurrent(canonical)
|
|
&& canonical.Key == key
|
|
&& _entityObjects.TryGetInitialCreateResidence(canonical, out _);
|
|
|
|
/// <summary>
|
|
/// C3c: true when the exact incarnation behind <paramref name="canonical"/>
|
|
/// holds an initial-create residence lease. Used by materialization to
|
|
/// decide whether presentation must await the conductor's completion
|
|
/// receipt or may self-project from already-committed canonical state.
|
|
/// </summary>
|
|
internal bool HasActiveInitialCreateResidence(
|
|
RuntimeEntityRecord canonical) =>
|
|
_entityObjects.TryGetInitialCreateResidence(canonical, out _);
|
|
|
|
/// <summary>
|
|
/// C3c-R1 review F1: the ONLY sanctioned mutation of the otherwise
|
|
/// sticky <see cref="LiveEntityRecord.MaterializationResidence"/> — a
|
|
/// world-created (residence-managed) entity converting to an attached
|
|
/// projection at a same-incarnation kind transition (the equipped-child
|
|
/// world→attached path). Attached children have no Runtime placement,
|
|
/// so the sticky-residence rule expects them to carry
|
|
/// <see cref="LiveEntityMaterializationResidence.LegacyImmediate"/>.
|
|
/// Owned here so the invariant is asserted at the owner: converting
|
|
/// while the initial-create residence is still ACTIVE would let an
|
|
/// attached materialization race the conductor's pending placement.
|
|
/// </summary>
|
|
internal void ConvertMaterializationResidenceToLegacyImmediate(
|
|
LiveEntityRecord record)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(record);
|
|
if (record.MaterializationResidence is not
|
|
LiveEntityMaterializationResidence.AwaitRuntimePlacement)
|
|
{
|
|
return;
|
|
}
|
|
if (HasActiveInitialCreateResidence(record.Canonical))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{record.ServerGuid:X8}/"
|
|
+ $"{record.Canonical.Incarnation} cannot convert to "
|
|
+ "LegacyImmediate residence while its initial-create "
|
|
+ "residence lease is still active.");
|
|
}
|
|
record.MaterializationResidence =
|
|
LiveEntityMaterializationResidence.LegacyImmediate;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies one canonical Runtime placement receipt to the graphical
|
|
/// sidecar only. Runtime has already committed identity, position,
|
|
/// collision residence, object-clock state, and simulation worksets.
|
|
/// </summary>
|
|
internal bool TryApplyRuntimePlacementProjection(
|
|
in RuntimePlacementProjectionSnapshot projection)
|
|
{
|
|
if (projection.Kind is RuntimePlacementProjectionKind.Discard)
|
|
{
|
|
// Discard cancels only an unacknowledged observation. If its Place
|
|
// was already projected, retail keeps that last committed frame
|
|
// visible until a later canonical Place or Withdraw supersedes it.
|
|
return true;
|
|
}
|
|
|
|
if (projection.Kind is RuntimePlacementProjectionKind.ExecutorCompleted)
|
|
{
|
|
// F1: acknowledge-and-ignore, same as Discard. Must NOT fall
|
|
// through to the record-lookup/spatial-load gates below - those
|
|
// legitimately reject for reasons unrelated to this receipt (no
|
|
// sidecar yet, destination backend not loaded), and a false
|
|
// return here wedges the whole ordered placement stream at the
|
|
// FIFO head (RuntimePlacementProjectionSubscription's contract).
|
|
// NOT inert: RuntimeInitialCreateContinuationExecutor's Released
|
|
// arm calls PublishExecutorCompletion in production, and
|
|
// RuntimePlacementPresentationSink binds presentation off that
|
|
// receipt (C3c) rather than ignoring it the way this method does.
|
|
return true;
|
|
}
|
|
|
|
RuntimePlacementProjectionToken token = projection.Token;
|
|
if (!TryGetRuntimePlacementProjectionRecord(
|
|
token,
|
|
requirePlacementVersions:
|
|
projection.Kind is RuntimePlacementProjectionKind.Place,
|
|
out LiveEntityRecord? record)
|
|
|| record.WorldEntity is not { } entity)
|
|
{
|
|
// A WithdrawalRestored receipt lands here for an entity that is
|
|
// gone, displaced by a newer incarnation, or not materialized:
|
|
// there is no prior projection left to restore, so this reports
|
|
// "nothing restored". Its ONLY caller acknowledges the receipt
|
|
// regardless rather than wedging the ordered stream - see
|
|
// RuntimePlacementPresentationSink.TryApplyWithdrawalRestoration.
|
|
return false;
|
|
}
|
|
if (projection.Kind is RuntimePlacementProjectionKind.Place
|
|
&& !_spatial.IsLoaded(
|
|
(token.ExactCellId & 0xFFFF0000u) | 0xFFFFu))
|
|
{
|
|
// A canonical SetPosition receipt is not permission to create a
|
|
// pending graphical bucket. Keep the FIFO head unacknowledged
|
|
// until the destination backend exists, otherwise GpuWorldState's
|
|
// later pending-drain edge escapes this exact receipt transaction.
|
|
//
|
|
// WithdrawalRestored deliberately does NOT take this gate: it
|
|
// re-installs a bucket the matching Withdraw removed rather than
|
|
// creating a new one, the legacy per-packet rebucket already
|
|
// permits a pending bucket for this same entity, and holding the
|
|
// FIFO head behind a streaming edge would wedge every later
|
|
// receipt for every entity.
|
|
return false;
|
|
}
|
|
|
|
return projection.Kind switch
|
|
{
|
|
RuntimePlacementProjectionKind.Place =>
|
|
TryApplyRuntimePlacementPlace(in projection, record, entity),
|
|
RuntimePlacementProjectionKind.Withdraw =>
|
|
TryApplyRuntimePlacementWithdrawal(token, record, entity),
|
|
RuntimePlacementProjectionKind.WithdrawalRestored =>
|
|
TryApplyRuntimePlacementPlace(
|
|
in projection,
|
|
record,
|
|
entity,
|
|
commitPose: false),
|
|
_ => false,
|
|
};
|
|
}
|
|
|
|
/// <param name="commitPose">
|
|
/// False for a <see cref="RuntimePlacementProjectionKind.WithdrawalRestored"/>
|
|
/// receipt. That receipt is the EXACT inverse of
|
|
/// <see cref="TryApplyRuntimePlacementWithdrawal"/>, which removed the
|
|
/// graphical projection without touching the sidecar's world pose - so the
|
|
/// rollback re-installs the projection without touching it either. The
|
|
/// entity's pose stays owned by the per-frame remote publication path that
|
|
/// kept running throughout the park.
|
|
/// </param>
|
|
private bool TryApplyRuntimePlacementPlace(
|
|
in RuntimePlacementProjectionSnapshot projection,
|
|
LiveEntityRecord record,
|
|
WorldEntity entity,
|
|
bool commitPose = true)
|
|
{
|
|
RuntimePlacementProjectionToken token = projection.Token;
|
|
RuntimeEntityKey key = token.Entity;
|
|
ulong projectionOperation = ++record.ProjectionMutationVersion;
|
|
|
|
if (commitPose)
|
|
{
|
|
entity.SetPosition(projection.WorldPosition);
|
|
entity.Rotation = projection.Orientation;
|
|
}
|
|
// #282: live entities carry ParentCellId only; EffectCellId is the
|
|
// outdoor dat stab / building-shell field (LandblockLoader:80,97).
|
|
entity.ParentCellId = token.ExactCellId;
|
|
record.IsSpatiallyProjected = true;
|
|
|
|
Exception? spatialNotificationFailure = null;
|
|
uint priorRebucketingGuid = _rebucketingGuid;
|
|
_rebucketingGuid = record.ServerGuid;
|
|
BeginPresentationOnlySpatialMutation(key);
|
|
try
|
|
{
|
|
try
|
|
{
|
|
_spatial.RebucketLiveEntity(key, entity, token.ExactCellId);
|
|
}
|
|
catch (AggregateException error)
|
|
{
|
|
spatialNotificationFailure = error;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
EndPresentationOnlySpatialMutation(key);
|
|
_rebucketingGuid = priorRebucketingGuid;
|
|
}
|
|
|
|
if (!IsCurrentProjectionOperation(
|
|
record.ServerGuid,
|
|
record,
|
|
projectionOperation)
|
|
|| !TryGetRuntimePlacementProjectionRecord(
|
|
token,
|
|
requirePlacementVersions: true,
|
|
out LiveEntityRecord? current)
|
|
|| !ReferenceEquals(current, record)
|
|
|| !ReferenceEquals(current.WorldEntity, entity))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
record.ServerGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure: null);
|
|
return false;
|
|
}
|
|
|
|
bool visible = _spatial.IsLiveEntityProjectionResident(key);
|
|
record.IsSpatiallyVisible = visible;
|
|
RefreshSpatialPresentationIndexes(record);
|
|
RefreshPresentation(record);
|
|
|
|
if (!IsCurrentProjectionOperation(
|
|
record.ServerGuid,
|
|
record,
|
|
projectionOperation))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
record.ServerGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure: null);
|
|
return false;
|
|
}
|
|
|
|
ThrowAfterCommittedProjectionChange(
|
|
record.ServerGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure: null);
|
|
return true;
|
|
}
|
|
|
|
private bool TryApplyRuntimePlacementWithdrawal(
|
|
in RuntimePlacementProjectionToken token,
|
|
LiveEntityRecord record,
|
|
WorldEntity entity)
|
|
{
|
|
RuntimeEntityKey key = token.Entity;
|
|
ulong projectionOperation = ++record.ProjectionMutationVersion;
|
|
record.IsSpatiallyProjected = false;
|
|
|
|
Exception? spatialNotificationFailure = null;
|
|
uint priorRebucketingGuid = _rebucketingGuid;
|
|
_rebucketingGuid = record.ServerGuid;
|
|
BeginPresentationOnlySpatialMutation(key);
|
|
try
|
|
{
|
|
try
|
|
{
|
|
_spatial.RemoveLiveEntityProjection(entity);
|
|
}
|
|
catch (AggregateException error)
|
|
{
|
|
spatialNotificationFailure = error;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
EndPresentationOnlySpatialMutation(key);
|
|
_rebucketingGuid = priorRebucketingGuid;
|
|
}
|
|
|
|
if (!IsCurrentProjectionOperation(
|
|
record.ServerGuid,
|
|
record,
|
|
projectionOperation)
|
|
|| !TryGetRuntimePlacementProjectionRecord(
|
|
token,
|
|
requirePlacementVersions: false,
|
|
out LiveEntityRecord? current)
|
|
|| !ReferenceEquals(current, record)
|
|
|| !ReferenceEquals(current.WorldEntity, entity))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
record.ServerGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure: null);
|
|
return false;
|
|
}
|
|
|
|
record.IsSpatiallyVisible = false;
|
|
RefreshSpatialPresentationIndexes(record);
|
|
RefreshPresentation(record);
|
|
|
|
if (!IsCurrentProjectionOperation(
|
|
record.ServerGuid,
|
|
record,
|
|
projectionOperation))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
record.ServerGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure: null);
|
|
return false;
|
|
}
|
|
|
|
ThrowAfterCommittedProjectionChange(
|
|
record.ServerGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure: null);
|
|
return true;
|
|
}
|
|
|
|
private bool TryGetRuntimePlacementProjectionRecord(
|
|
in RuntimePlacementProjectionToken token,
|
|
bool requirePlacementVersions,
|
|
out LiveEntityRecord record)
|
|
{
|
|
if (!token.IsValid
|
|
|| token.SessionLifetimeVersion != _directory.SessionLifetimeVersion
|
|
|| !_projections.TryGet(token.Entity, out record!)
|
|
|| !_directory.IsCurrent(record.Canonical)
|
|
|| record.Canonical.Key != token.Entity
|
|
|| RequireProjectionKey(record) != token.Entity
|
|
|| !IsValidPortalPlacementAuthority(token))
|
|
{
|
|
record = null!;
|
|
return false;
|
|
}
|
|
|
|
if (requirePlacementVersions
|
|
&& (record.Canonical.PositionAuthorityVersion
|
|
!= token.PositionAuthorityVersion
|
|
|| record.Canonical.SpatialAuthorityVersion
|
|
!= token.SpatialAuthorityVersion
|
|
|| record.Canonical.PlacementCommitVersion
|
|
!= token.PlacementCommitVersion
|
|
|| record.Canonical.FullCellId != token.ExactCellId))
|
|
{
|
|
record = null!;
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool IsValidPortalPlacementAuthority(
|
|
in RuntimePlacementProjectionToken token)
|
|
{
|
|
RuntimePortalPlacementAuthority portal = token.Portal;
|
|
if (!portal.Present)
|
|
return portal.IsEmpty;
|
|
|
|
return portal.IsValid
|
|
&& portal.Projection.DestinationCell == token.ExactCellId;
|
|
}
|
|
|
|
private void BeginPresentationOnlySpatialMutation(RuntimeEntityKey key)
|
|
{
|
|
_presentationOnlySpatialMutationDepth.TryGetValue(key, out int depth);
|
|
_presentationOnlySpatialMutationDepth[key] = checked(depth + 1);
|
|
}
|
|
|
|
private void EndPresentationOnlySpatialMutation(RuntimeEntityKey key)
|
|
{
|
|
if (!_presentationOnlySpatialMutationDepth.TryGetValue(
|
|
key,
|
|
out int depth)
|
|
|| depth <= 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Presentation-only spatial mutation depth was not balanced.");
|
|
}
|
|
|
|
if (depth == 1)
|
|
_presentationOnlySpatialMutationDepth.Remove(key);
|
|
else
|
|
_presentationOnlySpatialMutationDepth[key] = depth - 1;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes only the render-bucket reference. The logical record and every
|
|
/// create-time resource remain alive for later projection/rebucketing.
|
|
/// </summary>
|
|
public bool WithdrawLiveEntityProjection(uint serverGuid)
|
|
{
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is null)
|
|
return false;
|
|
|
|
RuntimeEntityKey key = RequireProjectionKey(record);
|
|
bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical);
|
|
ulong objectClockEpoch = record.ObjectClockEpoch;
|
|
ulong projectionOperation = ++record.ProjectionMutationVersion;
|
|
Exception? spatialNotificationFailure = null;
|
|
try
|
|
{
|
|
_spatial.RemoveLiveEntityProjection(record.WorldEntity);
|
|
}
|
|
catch (AggregateException error)
|
|
{
|
|
// The projection is already absent and GpuWorldState has already
|
|
// drained its observer queue. Complete canonical withdrawal below
|
|
// before reporting the notification failure.
|
|
spatialNotificationFailure = error;
|
|
}
|
|
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure: null);
|
|
return false;
|
|
}
|
|
// Usually GpuWorldState delivers the false edge synchronously above.
|
|
// During a reentrant visibility callback it queues that edge until the
|
|
// outer notification completes, so publish the logical withdrawal now;
|
|
// OnSpatialVisibilityChanged rejects the later duplicate against this
|
|
// committed record state.
|
|
bool spatialEdgeWasDeferred = record.IsSpatiallyVisible;
|
|
_projections.SetVisible(record, false);
|
|
record.IsSpatiallyProjected = false;
|
|
record.IsSpatiallyVisible = false;
|
|
// GpuWorldState normally publishes the leave edge synchronously and
|
|
// OnSpatialVisibilityChanged suspends this exact clock there. During a
|
|
// reentrant notification the edge is deferred, so commit the missing
|
|
// suspension here. One root-workset leave is one epoch edge -- never
|
|
// advance it once in the observer and again in this owner.
|
|
if (wasOrdinaryRoot && record.ObjectClockEpoch == objectClockEpoch)
|
|
record.SuspendObjectClock();
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
Exception? runtimeNotificationFailure = null;
|
|
if (spatialEdgeWasDeferred)
|
|
{
|
|
try
|
|
{
|
|
PublishProjectionVisibilityChanged(record, false);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
runtimeNotificationFailure = error;
|
|
}
|
|
}
|
|
// The manually published deferred edge is an arbitrary callback
|
|
// boundary just like Rebucket's final visibility edge. It may
|
|
// reproject this record or replace the GUID; the displaced outer
|
|
// withdrawal must not report itself as the current operation.
|
|
if (!IsCurrentProjectionOperation(serverGuid, record, projectionOperation))
|
|
{
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure);
|
|
return false;
|
|
}
|
|
ThrowAfterCommittedProjectionChange(
|
|
serverGuid,
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exact-incarnation withdrawal used after App leave-world callbacks. A
|
|
/// callback may accept a newer INSTANCE_TS for the same GUID; that newer
|
|
/// projection must never be withdrawn by the displaced operation.
|
|
/// </summary>
|
|
public bool WithdrawLiveEntityProjection(LiveEntityRecord expectedRecord)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
return WithdrawLiveEntityProjection(
|
|
expectedRecord,
|
|
expectedRecord.PositionAuthorityVersion,
|
|
expectedRecord.ProjectionMutationVersion);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exact-operation withdrawal used across arbitrary App callbacks. Both
|
|
/// the accepted Position authority and projection mutation token are
|
|
/// pinned so a same-incarnation re-entry cannot be removed by an older
|
|
/// parent/pickup/leave-world operation.
|
|
/// </summary>
|
|
internal bool WithdrawLiveEntityProjection(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedPositionAuthorityVersion,
|
|
ulong expectedProjectionMutationVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
return IsCurrentRecord(expectedRecord)
|
|
&& expectedRecord.PositionAuthorityVersion == expectedPositionAuthorityVersion
|
|
&& expectedRecord.ProjectionMutationVersion == expectedProjectionMutationVersion
|
|
&& WithdrawLiveEntityProjection(expectedRecord.ServerGuid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Withdraws a still-live projection whose authoritative rollback frame
|
|
/// is outside every world cell. Logical owners remain registered.
|
|
/// </summary>
|
|
internal bool WithdrawLiveEntityProjectionToCellless(uint serverGuid)
|
|
{
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!_entityObjects.CommitWithdrawal(
|
|
record.Canonical,
|
|
AcknowledgeCelllessCanonicalCommit))
|
|
{
|
|
return false;
|
|
}
|
|
record.WorldEntity.ParentCellId = 0u;
|
|
return WithdrawLiveEntityProjection(serverGuid);
|
|
}
|
|
|
|
public bool UnregisterLiveEntity(
|
|
DeleteObject.Parsed delete,
|
|
bool isLocalPlayer,
|
|
bool removeRetainedObject = false,
|
|
Action? beforeTeardown = null)
|
|
{
|
|
if (_isRegisteringResources)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"A live entity cannot unregister from inside atomic resource registration.");
|
|
}
|
|
var teardownKey = (delete.Guid, delete.InstanceSequence);
|
|
LiveEntityRecord? record = null;
|
|
RuntimeEntityRecord? canonicalOnly = null;
|
|
if (_directory.TryGetTeardown(
|
|
teardownKey.Guid,
|
|
teardownKey.InstanceSequence,
|
|
out RuntimeEntityRecord retainedCanonical))
|
|
{
|
|
_projections.TryGetTeardown(retainedCanonical, out record);
|
|
}
|
|
bool retryingAcceptedDelete = record is not null
|
|
&& (record.DeleteAcceptedForTeardown
|
|
|| _isClearing
|
|
|| _sessionClearPendingFinalization);
|
|
RuntimeEntityDeleteAcceptance? acceptedDelete = null;
|
|
if (!retryingAcceptedDelete)
|
|
{
|
|
if (!_entityObjects.TryAcceptDelete(
|
|
delete,
|
|
isLocalPlayer,
|
|
removeRetainedObject,
|
|
out acceptedDelete))
|
|
return false;
|
|
|
|
// End active gameplay identity before arbitrary callbacks so a
|
|
// newer generation can be accepted re-entrantly, but retain the
|
|
// exact incarnation as a teardown tombstone until unregister has
|
|
// succeeded. A throwing resource callback can then be retried by
|
|
// the same accepted Delete instead of orphaning its owner.
|
|
LiveEntityRecord? retainedRegistrationFailure = record;
|
|
if (acceptedDelete.RetiredCanonical is { } activeCanonical)
|
|
{
|
|
if (_projections.TryGet(
|
|
activeCanonical,
|
|
out LiveEntityRecord? activeRecord))
|
|
{
|
|
if (!_projections.RemoveActive(activeRecord))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Exact App projection for 0x{delete.Guid:X8}/{delete.InstanceSequence} could not be retired.");
|
|
}
|
|
record = activeRecord;
|
|
RetainTeardownRecord(record);
|
|
}
|
|
else
|
|
{
|
|
canonicalOnly = activeCanonical;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
record = retainedRegistrationFailure;
|
|
}
|
|
if (record is not null)
|
|
record.DeleteAcceptedForTeardown = true;
|
|
}
|
|
|
|
List<Exception>? failures = null;
|
|
_logicalTeardownDepth++;
|
|
try
|
|
{
|
|
if (!retryingAcceptedDelete)
|
|
{
|
|
try
|
|
{
|
|
_entityObjects.CompleteAcceptedDelete(acceptedDelete!);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
}
|
|
|
|
try
|
|
{
|
|
beforeTeardown?.Invoke();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
}
|
|
}
|
|
|
|
if (record is not null)
|
|
{
|
|
try
|
|
{
|
|
TearDownRecord(record);
|
|
ReleaseTeardownRecord(record);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
}
|
|
}
|
|
else if (canonicalOnly is not null)
|
|
{
|
|
TearDownCanonicalOnly(canonicalOnly);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_logicalTeardownDepth--;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Persistence is GUID-scoped. End it only when the accepted
|
|
// delete left no replacement incarnation or unfinished teardown
|
|
// behind.
|
|
if (!_directory.TryGetActive(delete.Guid, out _)
|
|
&& !HasPendingTeardown(delete.Guid))
|
|
{
|
|
_spatial.ForgetLiveEntity(delete.Guid);
|
|
}
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
}
|
|
|
|
if (failures is not null)
|
|
throw new AggregateException(
|
|
$"Live entity 0x{delete.Guid:X8} deletion cleanup failed.",
|
|
failures);
|
|
return true;
|
|
}
|
|
|
|
public bool TryGetRecord(uint serverGuid, out LiveEntityRecord record) =>
|
|
_projections.TryGetCurrent(serverGuid, out record!);
|
|
|
|
public bool TryGetWorldEntity(uint serverGuid, out WorldEntity entity)
|
|
{
|
|
if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
&& record.WorldEntity is { } found)
|
|
{
|
|
entity = found;
|
|
return true;
|
|
}
|
|
|
|
entity = null!;
|
|
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 current equipped-child projection whose composed frame is
|
|
/// live. Retail installs an equipped item as a first-class
|
|
/// <c>CPhysicsObj</c> with its own object id and part array
|
|
/// (<c>CPhysicsObj::add_child @ 0x0050F870</c> via
|
|
/// <c>CSetup::GetHoldingLocation @ 0x005213F0</c>), and
|
|
/// <c>CPhysicsObj::UpdateChild @ 0x00512D50</c> recomposes
|
|
/// <c>Frame::combine(parent part frame, holding frame)</c> into that child's
|
|
/// own <c>m_position</c> every frame. An attached projection therefore has
|
|
/// real world presence even though it is deliberately absent from the
|
|
/// interaction/radar/auto-target visible set.
|
|
/// </summary>
|
|
public bool TryGetAttachedProjectedRecord(
|
|
uint serverGuid,
|
|
out LiveEntityRecord record)
|
|
{
|
|
if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord found)
|
|
&& found.WorldEntity is not null
|
|
&& found.ProjectionKind is LiveEntityProjectionKind.Attached
|
|
&& found.IsSpatiallyProjected)
|
|
{
|
|
record = found;
|
|
return true;
|
|
}
|
|
|
|
record = null!;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves an object that currently participates in mouse picking.
|
|
/// Retail's only candidacy rule is "a drawn part whose owning physics
|
|
/// object has a nonzero id" (<c>CPhysicsPart::Draw @ 0x0050D7A0</c> guards
|
|
/// on <c>CPhysicsPart::get_physobj_id @ 0x0050D490</c>, and
|
|
/// <c>Render::GfxObjUnderSelectionRay @ 0x0054C740</c> accumulates the
|
|
/// hit under that id) — there is no parent redirection and no
|
|
/// wielded-specific gate, so a click on a wielded weapon returns the
|
|
/// weapon's own GUID. Picking therefore admits attached projections on top
|
|
/// of the ordinary top-level visible set. This is deliberately NOT the
|
|
/// interaction-eligible predicate: radar, auto-target, and
|
|
/// MoveTo/Sticky establishment must stay wielded-item free.
|
|
/// </summary>
|
|
public bool TryGetPickEligibleRecord(
|
|
uint serverGuid,
|
|
out LiveEntityRecord record)
|
|
=> TryGetInteractionEligibleRecord(serverGuid, out record)
|
|
|| TryGetAttachedProjectedRecord(serverGuid, out record);
|
|
|
|
/// <summary>
|
|
/// Pick eligibility bound to one logical incarnation. A stale published
|
|
/// frame must never retarget a replacement which reused the server GUID.
|
|
/// </summary>
|
|
public bool TryGetPickEligibleRecord(
|
|
uint serverGuid,
|
|
uint localEntityId,
|
|
out LiveEntityRecord record)
|
|
{
|
|
if (serverGuid != 0u
|
|
&& localEntityId != 0u
|
|
&& TryGetPickEligibleRecord(serverGuid, out LiveEntityRecord found)
|
|
&& found.WorldEntity!.Id == localEntityId)
|
|
{
|
|
record = found;
|
|
return true;
|
|
}
|
|
|
|
record = null!;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a top-level object that currently participates in
|
|
/// targeting, radar, and wire-driven MoveTo/Sticky establishment.
|
|
/// Pending, attached, and Hidden projections are intentionally excluded.
|
|
/// </summary>
|
|
public bool TryGetInteractionEligibleEntity(
|
|
uint serverGuid,
|
|
out WorldEntity entity)
|
|
{
|
|
if (_projections.TryGetVisibleCurrent(
|
|
serverGuid,
|
|
out LiveEntityRecord record)
|
|
&& record.WorldEntity is { } visible)
|
|
{
|
|
entity = visible;
|
|
return true;
|
|
}
|
|
|
|
entity = null!;
|
|
return false;
|
|
}
|
|
|
|
bool ILiveEntityRadarSource.TryGetMaterialized(
|
|
uint serverGuid,
|
|
out WorldEntity entity) =>
|
|
TryGetWorldEntity(serverGuid, out entity);
|
|
|
|
bool ILiveEntityRadarSource.TryGetVisible(
|
|
uint serverGuid,
|
|
out WorldEntity entity) =>
|
|
TryGetInteractionEligibleEntity(serverGuid, out entity);
|
|
|
|
void ILiveEntityRadarSource.CopyVisibleTo(
|
|
List<KeyValuePair<uint, WorldEntity>> destination)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(destination);
|
|
destination.Clear();
|
|
foreach (LiveEntityRecord record in _projections.VisibleRecords)
|
|
{
|
|
destination.Add(new KeyValuePair<uint, WorldEntity>(
|
|
record.ServerGuid,
|
|
record.WorldEntity!));
|
|
}
|
|
}
|
|
|
|
public bool TryGetInteractionEligibleRecord(
|
|
uint serverGuid,
|
|
out LiveEntityRecord record)
|
|
{
|
|
if (_projections.TryGetVisibleCurrent(
|
|
serverGuid,
|
|
out LiveEntityRecord found)
|
|
&& found.WorldEntity is not null)
|
|
{
|
|
record = found;
|
|
return true;
|
|
}
|
|
|
|
record = null!;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a render-published interaction candidate only while the same
|
|
/// logical WorldEntity incarnation still owns the server GUID. A stale
|
|
/// frame must never retarget a replacement which reused that GUID.
|
|
/// </summary>
|
|
public bool TryGetInteractionEligibleRecord(
|
|
uint serverGuid,
|
|
uint localEntityId,
|
|
out LiveEntityRecord record)
|
|
{
|
|
if (serverGuid != 0u
|
|
&& localEntityId != 0u
|
|
&& TryGetInteractionEligibleRecord(serverGuid, out LiveEntityRecord found)
|
|
&& found.WorldEntity!.Id == localEntityId)
|
|
{
|
|
record = found;
|
|
return true;
|
|
}
|
|
|
|
record = null!;
|
|
return false;
|
|
}
|
|
|
|
public bool ContainsWorldEntity(uint serverGuid) =>
|
|
_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
&& record.WorldEntity is not null;
|
|
|
|
public bool TryGetServerGuid(uint localEntityId, out uint serverGuid)
|
|
{
|
|
if (_directory.TryGetByLocalId(
|
|
localEntityId,
|
|
out RuntimeEntityRecord canonical))
|
|
{
|
|
serverGuid = canonical.ServerGuid;
|
|
return true;
|
|
}
|
|
|
|
serverGuid = 0u;
|
|
return false;
|
|
}
|
|
|
|
internal bool TryGetRecordByLocalEntityId(
|
|
uint localEntityId,
|
|
out LiveEntityRecord record) =>
|
|
_projections.TryGetByLocalId(localEntityId, out record);
|
|
|
|
internal bool TryGetRecord(
|
|
RuntimeEntityKey key,
|
|
out LiveEntityRecord record) =>
|
|
_projections.TryGet(key, out record);
|
|
|
|
public bool TryGetLocalEntityId(uint serverGuid, out uint localEntityId)
|
|
{
|
|
if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
&& record.LocalEntityId is { } found)
|
|
{
|
|
localEntityId = found;
|
|
return true;
|
|
}
|
|
|
|
localEntityId = 0;
|
|
return false;
|
|
}
|
|
|
|
public void SetAnimationRuntime(uint serverGuid, ILiveEntityAnimationRuntime runtime)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(runtime);
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is not { } entity)
|
|
throw new InvalidOperationException($"Cannot bind animation before live entity 0x{serverGuid:X8} is materialized.");
|
|
if (!ReferenceEquals(runtime.Entity, entity))
|
|
throw new InvalidOperationException("Animation runtime belongs to a different WorldEntity.");
|
|
|
|
_ = RequireProjectionKey(record);
|
|
record.AnimationRuntime = runtime;
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
}
|
|
|
|
public bool TryGetAnimationRuntime(
|
|
uint localEntityId,
|
|
out ILiveEntityAnimationRuntime runtime)
|
|
{
|
|
if (_projections.TryGetByLocalId(
|
|
localEntityId,
|
|
out LiveEntityRecord record)
|
|
&& record.AnimationRuntime is { } found)
|
|
{
|
|
runtime = found;
|
|
return true;
|
|
}
|
|
|
|
runtime = null!;
|
|
return false;
|
|
}
|
|
|
|
internal bool TryGetProjectionKey(
|
|
uint serverGuid,
|
|
out RuntimeEntityKey key)
|
|
{
|
|
if (_projections.TryGetCurrent(
|
|
serverGuid,
|
|
out LiveEntityRecord? record)
|
|
&& record.ProjectionKey is { } found)
|
|
{
|
|
key = found;
|
|
return true;
|
|
}
|
|
|
|
key = default;
|
|
return false;
|
|
}
|
|
|
|
public bool ClearAnimationRuntime(uint serverGuid)
|
|
{
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
|| record.AnimationRuntime is null)
|
|
return false;
|
|
record.AnimationRuntime = null;
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
return true;
|
|
}
|
|
|
|
internal bool ClearAnimationRuntime(LiveEntityRecord expectedRecord)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
if (expectedRecord.AnimationRuntime is null
|
|
|| expectedRecord.ProjectionKey is not { } key
|
|
|| !_projections.TryGet(key, out LiveEntityRecord? record)
|
|
|| !ReferenceEquals(record, expectedRecord))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
record.AnimationRuntime = null;
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Acquires the one retail <c>CPhysicsObj</c> body for this exact live
|
|
/// incarnation. Static animation may need the body before a MovementManager
|
|
/// or projectile component exists; later components must adopt this same
|
|
/// instance instead of replaying CreateObject vector initialization.
|
|
/// </summary>
|
|
internal PhysicsBody GetOrCreatePhysicsBody(
|
|
uint serverGuid,
|
|
Func<LiveEntityRecord, PhysicsBody> factory)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(factory);
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Cannot acquire physics body before live entity 0x{serverGuid:X8} is materialized.");
|
|
}
|
|
bool ProjectionIsCurrent() =>
|
|
_projections.TryGetCurrent(
|
|
serverGuid,
|
|
out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& current.WorldEntity is not null;
|
|
return _physics.GetOrCreatePhysicsBody(
|
|
record.Canonical,
|
|
_ => factory(record),
|
|
ProjectionIsCurrent);
|
|
}
|
|
|
|
internal void SetRemoteMotionRuntime(uint serverGuid, IRuntimeRemoteMotion runtime)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(runtime);
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record))
|
|
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists.");
|
|
ulong lifetimeVersion = CurrentLifetimeMutation(serverGuid);
|
|
_physics.SetRemoteMotion(
|
|
record.Canonical,
|
|
runtime,
|
|
() => CurrentLifetimeMutation(serverGuid) == lifetimeVersion
|
|
&& _projections.TryGetCurrent(
|
|
serverGuid,
|
|
out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record));
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
}
|
|
|
|
public void InstallPhysicsHost(
|
|
LiveEntityRecord expectedRecord,
|
|
AcDream.Core.Physics.Motion.IPhysicsObjHost host)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
ArgumentNullException.ThrowIfNull(host);
|
|
uint serverGuid = expectedRecord.ServerGuid;
|
|
bool ProjectionIsCurrent() =>
|
|
_projections.TryGetCurrent(
|
|
serverGuid,
|
|
out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, expectedRecord);
|
|
_physics.InstallPhysicsHost(
|
|
expectedRecord.Canonical,
|
|
host,
|
|
ProjectionIsCurrent);
|
|
}
|
|
|
|
public bool TryGetPhysicsHost(
|
|
uint serverGuid,
|
|
out AcDream.Core.Physics.Motion.IPhysicsObjHost host)
|
|
{
|
|
if (_projections.TryGetCurrent(serverGuid, out _)
|
|
&& _physics.TryGetPhysicsHost(serverGuid, out var existing))
|
|
{
|
|
host = existing;
|
|
return true;
|
|
}
|
|
|
|
host = null!;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// R2 fix round (2026-08-04): the App-layer entry point for retail
|
|
/// <c>CPhysicsObj::report_collision_end(this, 1)</c> —
|
|
/// <c>teleport_hook</c>'s sixth action (@0x00514F31 → @0x00514620,
|
|
/// force-end-all with bidirectional <c>DoCollisionEnd</c> on both this
|
|
/// object and every collision-table partner). Forwards to
|
|
/// <see cref="RuntimeCollisionReportingState.LeaveWorld"/>, the exact
|
|
/// retail leave-world/teleport/Hidden force-end entry point — NOT
|
|
/// <c>ShadowObjectRegistry.Suspend</c>, which ports a DIFFERENT retail
|
|
/// function (<c>remove_shadows_from_cells</c>) that <c>teleport_hook</c>
|
|
/// never calls; that mapping predated this fix and was carried without
|
|
/// re-derivation.
|
|
/// </summary>
|
|
internal void ForceEndCollisionReporting(RuntimeEntityRecord canonical) =>
|
|
_physics.CollisionReports.LeaveWorld(canonical);
|
|
|
|
public bool TryGetRemoteMotionRuntime(
|
|
uint serverGuid,
|
|
out IRuntimeRemoteMotion runtime)
|
|
{
|
|
if (_projections.TryGetCurrent(
|
|
serverGuid,
|
|
out LiveEntityRecord? record)
|
|
&& record.RemoteMotionRuntime is { } found)
|
|
{
|
|
runtime = found;
|
|
return true;
|
|
}
|
|
|
|
runtime = null!;
|
|
return false;
|
|
}
|
|
|
|
internal bool ClearRemoteMotionRuntime(uint serverGuid)
|
|
{
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
|| record.RemoteMotionRuntime is null)
|
|
return false;
|
|
if (!_physics.ClearRemoteMotion(record.Canonical))
|
|
return false;
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
return true;
|
|
}
|
|
|
|
internal RemoteMotion GetOrCreateRemoteMotionRuntime(uint serverGuid)
|
|
{
|
|
if (!_projections.TryGetCurrent(
|
|
serverGuid,
|
|
out LiveEntityRecord? record))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Cannot acquire remote motion before live entity 0x{serverGuid:X8} exists.");
|
|
}
|
|
|
|
bool ProjectionIsCurrent() =>
|
|
_projections.TryGetCurrent(
|
|
serverGuid,
|
|
out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record);
|
|
return _physics.GetOrCreateRemoteMotion(
|
|
record.Canonical,
|
|
ProjectionIsCurrent);
|
|
}
|
|
|
|
internal IRuntimeProjectile BindProjectileRuntime(
|
|
uint serverGuid,
|
|
PhysicsBody body,
|
|
ProjectileCollisionSphere collisionSphere,
|
|
Func<bool>? externalOwnerValid = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(body);
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized.");
|
|
}
|
|
if (record.PhysicsBodyAcquisitionInProgress && record.PhysicsBody is null)
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} cannot bind projectile physics during physics-body acquisition.");
|
|
if (record.PhysicsBody is { } canonicalBody
|
|
&& !ReferenceEquals(canonicalBody, body))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{serverGuid:X8} cannot replace its canonical physics body within one incarnation.");
|
|
}
|
|
|
|
IRuntimeProjectile runtime = _physics.BindProjectile(
|
|
record.Canonical,
|
|
body,
|
|
collisionSphere,
|
|
() => IsCurrentRecord(record)
|
|
&& record.WorldEntity is { } entity
|
|
&& record.LocalEntityId == entity.Id
|
|
&& (externalOwnerValid?.Invoke() ?? true));
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
return runtime;
|
|
}
|
|
|
|
public bool TryGetProjectileRuntime(
|
|
uint serverGuid,
|
|
out IRuntimeProjectile runtime)
|
|
{
|
|
if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
&& record.ProjectileRuntime is { } found)
|
|
{
|
|
runtime = found;
|
|
return true;
|
|
}
|
|
|
|
runtime = null!;
|
|
return false;
|
|
}
|
|
|
|
public bool ClearProjectileRuntime(uint serverGuid)
|
|
{
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
|| record.ProjectileRuntime is null)
|
|
return false;
|
|
|
|
_physics.ClearProjectile(record.Canonical);
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
return true;
|
|
}
|
|
|
|
public void SetEffectProfile(uint serverGuid, ILiveEntityEffectProfile profile)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(profile);
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record))
|
|
throw new InvalidOperationException(
|
|
$"Cannot bind an effect profile before live entity 0x{serverGuid:X8} exists.");
|
|
record.EffectProfile = profile;
|
|
}
|
|
|
|
public bool TryGetEffectProfile(
|
|
uint serverGuid,
|
|
out ILiveEntityEffectProfile profile)
|
|
{
|
|
if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
&& record.EffectProfile is { } found)
|
|
{
|
|
profile = found;
|
|
return true;
|
|
}
|
|
|
|
profile = null!;
|
|
return false;
|
|
}
|
|
|
|
public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) =>
|
|
_directory.TryGetSnapshot(guid, out spawn);
|
|
|
|
public bool TryApplyObjDesc(ObjDescEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
|
=> _entityObjects.TryApplyObjDesc(
|
|
update,
|
|
canonical =>
|
|
{
|
|
if (_projections.TryGet(
|
|
canonical,
|
|
out LiveEntityRecord? record))
|
|
{
|
|
record.NoteObjDescProjectionSynchronization();
|
|
}
|
|
},
|
|
out accepted);
|
|
|
|
public bool TryApplyPickup(PickupEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
|
=> _entityObjects.TryApplyPickup(
|
|
update,
|
|
AcknowledgeCelllessCanonicalCommit,
|
|
out accepted);
|
|
|
|
public bool TryApplyCreateParent(CreateParentUpdate update, out WorldSession.EntitySpawn accepted)
|
|
=> _entityObjects.TryApplyCreateParent(
|
|
update,
|
|
acknowledgeProjection: null,
|
|
out accepted);
|
|
|
|
public bool TryApplyParent(ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted)
|
|
=> _entityObjects.TryApplyParent(
|
|
update,
|
|
acknowledgeProjection: null,
|
|
out accepted);
|
|
|
|
internal bool CommitStagedParent(
|
|
ParentAttachmentRelation relation,
|
|
out WorldSession.EntitySpawn accepted)
|
|
=> _entityObjects.TryCommitParent(
|
|
relation,
|
|
acknowledgeProjection: null,
|
|
out accepted);
|
|
|
|
/// <summary>
|
|
/// Commits retail <c>CPhysicsObj::set_parent</c>'s cell-less edge only
|
|
/// after the parent accepted the child (PartArray + holding-location
|
|
/// validation). The POSITION_TS is consumed before this step, matching
|
|
/// retail; invalid parent relationships retain the old world projection.
|
|
/// Since C4 route 7 D1, this also completes retail's second half — if
|
|
/// the committed parent is celled, the child is re-celled to the
|
|
/// parent's exact values in the SAME transaction (see
|
|
/// <see cref="RuntimeEntityObjectLifetime.CommitAcceptedParentCellless"/>).
|
|
/// A cell-less parent leaves the child cell-less, exactly as before,
|
|
/// now by the retail-cited gate instead of by omission; a later parent
|
|
/// cell commit re-cells the child through D2's propagation.
|
|
/// </summary>
|
|
internal bool CommitAcceptedParentCellless(
|
|
LiveEntityRecord record,
|
|
ulong positionAuthorityVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(record);
|
|
return _entityObjects.CommitAcceptedParentCellless(
|
|
record.Canonical,
|
|
positionAuthorityVersion,
|
|
AcknowledgeCelllessCanonicalCommit);
|
|
}
|
|
|
|
internal bool CommitAcceptedParentCellless(
|
|
RuntimeEntityRecord canonical,
|
|
ulong positionAuthorityVersion)
|
|
{
|
|
return _entityObjects.CommitAcceptedParentCellless(
|
|
canonical,
|
|
positionAuthorityVersion,
|
|
AcknowledgeCelllessCanonicalCommit);
|
|
}
|
|
|
|
public bool TryApplyMotion(
|
|
WorldSession.EntityMotionUpdate update,
|
|
bool retainPayload,
|
|
out WorldSession.EntitySpawn accepted,
|
|
out AcceptedPhysicsTimestamps timestamps)
|
|
=> _entityObjects.TryApplyMotion(
|
|
update,
|
|
retainPayload,
|
|
acknowledgeProjection: null,
|
|
out accepted,
|
|
out timestamps);
|
|
|
|
public bool TryApplyVector(VectorUpdate.Parsed update, out WorldSession.EntitySpawn accepted)
|
|
=> _entityObjects.TryApplyVector(
|
|
update,
|
|
acknowledgeProjection: null,
|
|
out accepted);
|
|
|
|
public bool TryApplyState(SetState.Parsed update, out WorldSession.EntitySpawn accepted)
|
|
=> TryApplyState(update, out accepted, out _);
|
|
|
|
public bool TryApplyState(
|
|
SetState.Parsed update,
|
|
out WorldSession.EntitySpawn accepted,
|
|
out RetailPhysicsStateTransition transition)
|
|
=> _entityObjects.TryApplyState(
|
|
update,
|
|
(canonical, _) =>
|
|
{
|
|
if (_projections.TryGet(
|
|
canonical,
|
|
out LiveEntityRecord? record))
|
|
{
|
|
RefreshPresentation(record);
|
|
}
|
|
},
|
|
out accepted,
|
|
out transition);
|
|
|
|
/// <summary>
|
|
/// Retail parent Hidden directly toggles each attached child's NoDraw bit
|
|
/// without consuming the child's STATE_TS or replacing its raw wire state.
|
|
/// </summary>
|
|
public bool SetAttachedChildNoDraw(uint childServerGuid, bool noDraw)
|
|
{
|
|
if (!_projections.TryGetCurrent(childServerGuid, out LiveEntityRecord? record))
|
|
return false;
|
|
return _entityObjects.CommitChildNoDraw(
|
|
record.Canonical,
|
|
noDraw,
|
|
_ => RefreshPresentation(record));
|
|
}
|
|
|
|
public bool TryApplyPosition(
|
|
WorldSession.EntityPositionUpdate update,
|
|
bool isLocalPlayer,
|
|
System.Numerics.Quaternion? forcePositionRotation,
|
|
System.Numerics.Vector3? currentLocalVelocity,
|
|
out PositionTimestampDisposition disposition,
|
|
out WorldSession.EntitySpawn accepted,
|
|
out AcceptedPhysicsTimestamps timestamps) =>
|
|
_entityObjects.TryApplyPosition(
|
|
update,
|
|
isLocalPlayer,
|
|
forcePositionRotation,
|
|
currentLocalVelocity,
|
|
acknowledgeProjection: null,
|
|
out disposition,
|
|
out accepted,
|
|
out timestamps);
|
|
|
|
/// <summary>
|
|
/// C4 route 4a: borrows the canonical Runtime classification for one
|
|
/// remote's accepted Position. App supplies only the two things it owns —
|
|
/// the canonical record and the live local-player distance — and never
|
|
/// assembles an authority, a generation token, or a route request of its
|
|
/// own. Returns <see langword="null"/> when Runtime declines to classify;
|
|
/// see <see cref="RuntimeEntityObjectLifetime.ClassifyRemoteAcceptedPosition"/>.
|
|
/// </summary>
|
|
internal RuntimeAuthoritativePositionRoute? ClassifyRemoteAcceptedPosition(
|
|
RuntimeEntityRecord canonical,
|
|
in AcDream.Core.Net.WorldSession.EntityPositionUpdate update,
|
|
AcDream.Core.Physics.PositionTimestampDisposition disposition,
|
|
in AcceptedPhysicsTimestamps timestamps,
|
|
float? playerDistance) =>
|
|
_entityObjects.ClassifyRemoteAcceptedPosition(
|
|
canonical,
|
|
update,
|
|
disposition,
|
|
timestamps,
|
|
playerDistance);
|
|
|
|
public bool IsFreshTeleportStart(uint localPlayerGuid, ushort teleportSequence) =>
|
|
_directory.IsFreshTeleportStart(localPlayerGuid, teleportSequence);
|
|
|
|
/// <summary>
|
|
/// Retail <c>CPhysicsObj::update_object</c> (<c>0x00515D40</c>) runs root
|
|
/// object tick only while the object has visible world-cell membership and
|
|
/// is not Frozen. Hidden is deliberately not a broad tick stop: retail
|
|
/// skips PartArray animation and physics, but still applies the
|
|
/// PositionManager offset and advances targeting/movement/script/particle
|
|
/// owners. A pickup or parent transition keeps
|
|
/// script/effect owners but clears the cell and withdraws the root
|
|
/// projection until a fresh Position re-enters it.
|
|
/// </summary>
|
|
public RetailObjectClockDisposition GetRootObjectClockDisposition(
|
|
uint serverGuid)
|
|
{
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
|| record.ProjectionKind is not LiveEntityProjectionKind.World
|
|
|| !record.IsSpatiallyProjected
|
|
|| !record.IsSpatiallyVisible
|
|
|| record.FullCellId == 0)
|
|
{
|
|
return RetailObjectClockDisposition.Suspend;
|
|
}
|
|
|
|
return (record.FinalPhysicsState & PhysicsStateFlags.Frozen) != 0
|
|
? RetailObjectClockDisposition.Suspend
|
|
: RetailObjectClockDisposition.Advance;
|
|
}
|
|
|
|
public bool ShouldAdvanceRootRuntime(uint serverGuid) =>
|
|
GetRootObjectClockDisposition(serverGuid)
|
|
is RetailObjectClockDisposition.Advance;
|
|
|
|
internal bool IsCurrentPositionAuthority(
|
|
LiveEntityRecord record,
|
|
ulong authorityVersion) =>
|
|
_projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& 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) =>
|
|
_projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& current.StateAuthorityVersion == authorityVersion;
|
|
|
|
internal bool IsCurrentVectorAuthority(
|
|
LiveEntityRecord record,
|
|
ulong authorityVersion) =>
|
|
_projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& current.VectorAuthorityVersion == authorityVersion;
|
|
|
|
internal bool IsCurrentVelocityAuthority(
|
|
LiveEntityRecord record,
|
|
ulong authorityVersion) =>
|
|
_projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& 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) =>
|
|
_projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& current.MovementAuthorityVersion == authorityVersion;
|
|
|
|
internal bool IsCurrentObjDescAuthority(
|
|
LiveEntityRecord record,
|
|
ulong authorityVersion) =>
|
|
_projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& current.ObjDescAuthorityVersion == authorityVersion;
|
|
|
|
/// <summary>
|
|
/// Copies the canonical loaded root-object workset used by retail
|
|
/// <c>CPhysics::UseTime</c>. The record reference is the incarnation token;
|
|
/// callers must revalidate it after callbacks that can delete or rebucket.
|
|
/// </summary>
|
|
internal void CopySpatialRootObjectRecordsTo(List<LiveEntityRecord> destination)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(destination);
|
|
destination.Clear();
|
|
_physics.CopySpatialRootsTo(_spatialRootCanonicalScratch);
|
|
for (int index = 0;
|
|
index < _spatialRootCanonicalScratch.Count;
|
|
index++)
|
|
{
|
|
RuntimeEntityRecord canonical =
|
|
_spatialRootCanonicalScratch[index];
|
|
if (_projections.TryGet(
|
|
canonical,
|
|
out LiveEntityRecord? current)
|
|
&& HasSpatialRuntimeProjection(current))
|
|
{
|
|
destination.Add(current);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal bool IsCurrentSpatialRootObject(LiveEntityRecord record) =>
|
|
IsCurrentRecord(record)
|
|
&& _physics.IsSpatialRoot(record.Canonical)
|
|
&& HasSpatialRuntimeProjection(record);
|
|
|
|
internal bool IsCurrentSpatialAnimation(
|
|
LiveEntityRecord record,
|
|
ILiveEntityAnimationRuntime animation) =>
|
|
IsCurrentSpatialRootObject(record)
|
|
&& ReferenceEquals(record.AnimationRuntime, animation)
|
|
&& record.ProjectionKey is { } key
|
|
&& _spatialAnimations.TryGetValue(key, out var indexed)
|
|
&& ReferenceEquals(indexed, animation);
|
|
|
|
internal bool IsCurrentSpatialAnimation(
|
|
uint localEntityId,
|
|
ILiveEntityAnimationRuntime animation) =>
|
|
_projections.TryGetByLocalId(localEntityId, out LiveEntityRecord record)
|
|
&& IsCurrentSpatialAnimation(record, animation);
|
|
|
|
/// <summary>
|
|
/// Returns whether <paramref name="animation"/> is still the canonical
|
|
/// animation component of this logical entity incarnation. Motion
|
|
/// completion belongs to that CPhysicsObj/PartArray lifetime, not to the
|
|
/// transient render-bucket projection used by per-frame presentation.
|
|
/// </summary>
|
|
internal bool IsCurrentAnimationOwner(
|
|
LiveEntityRecord record,
|
|
ILiveEntityAnimationRuntime animation) =>
|
|
IsCurrentRecord(record)
|
|
&& ReferenceEquals(record.AnimationRuntime, animation);
|
|
|
|
/// <summary>
|
|
/// Copies the currently spatial animation owners through the concrete
|
|
/// dictionary enumerator. This keeps per-frame traversal allocation-free;
|
|
/// exposing the workset as <see cref="IReadOnlyDictionary{TKey,TValue}"/>
|
|
/// would box its enumerator at a hot call site.
|
|
/// </summary>
|
|
internal void CopySpatialAnimationRuntimesTo<TAnimation>(
|
|
List<KeyValuePair<uint, TAnimation>> destination)
|
|
where TAnimation : class, ILiveEntityAnimationRuntime
|
|
{
|
|
ArgumentNullException.ThrowIfNull(destination);
|
|
destination.Clear();
|
|
foreach ((RuntimeEntityKey key, ILiveEntityAnimationRuntime animation)
|
|
in _spatialAnimations)
|
|
{
|
|
if (animation is TAnimation typed)
|
|
{
|
|
destination.Add(
|
|
new KeyValuePair<uint, TAnimation>(
|
|
key.LocalEntityId,
|
|
typed));
|
|
}
|
|
}
|
|
}
|
|
|
|
internal void CopySpatialAnimationLocalIdsTo(HashSet<uint> destination)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(destination);
|
|
destination.Clear();
|
|
foreach (RuntimeEntityKey key in _spatialAnimations.Keys)
|
|
destination.Add(key.LocalEntityId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Commits retail <c>CPhysicsObj::set_velocity</c> (0x005113F0) to the
|
|
/// canonical body for this exact incarnation. The body and the retained
|
|
/// object clock are one CPhysicsObj: a non-Static inactive-to-active edge
|
|
/// rebases both together, while Static preserves its prior Active state.
|
|
/// </summary>
|
|
internal bool TryCommitAuthoritativeVelocity(
|
|
LiveEntityRecord record,
|
|
PhysicsBody body,
|
|
Vector3 velocity,
|
|
double currentTime) =>
|
|
TryCommitAuthoritativeVectorCore(
|
|
record,
|
|
body,
|
|
velocity,
|
|
angularVelocity: null,
|
|
currentTime);
|
|
|
|
/// <summary>
|
|
/// Commits the paired retail VectorUpdate writes
|
|
/// (<c>set_velocity</c>, then <c>set_omega</c>) to one canonical body.
|
|
/// Validation is atomic: malformed wire vectors cannot partially mutate
|
|
/// the live object.
|
|
/// </summary>
|
|
internal bool TryCommitAuthoritativeVector(
|
|
LiveEntityRecord record,
|
|
PhysicsBody body,
|
|
Vector3 velocity,
|
|
Vector3 angularVelocity,
|
|
double currentTime) =>
|
|
TryCommitAuthoritativeVectorCore(
|
|
record,
|
|
body,
|
|
velocity,
|
|
angularVelocity,
|
|
currentTime);
|
|
|
|
private bool TryCommitAuthoritativeVectorCore(
|
|
LiveEntityRecord record,
|
|
PhysicsBody body,
|
|
Vector3 velocity,
|
|
Vector3? angularVelocity,
|
|
double currentTime)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(record);
|
|
ArgumentNullException.ThrowIfNull(body);
|
|
bool ProjectionIsCurrent() =>
|
|
_projections.TryGetCurrent(
|
|
record.ServerGuid,
|
|
out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& ReferenceEquals(current.PhysicsBody, body);
|
|
return _physics.TryCommitAuthoritativeVector(
|
|
record.Canonical,
|
|
body,
|
|
velocity,
|
|
angularVelocity,
|
|
currentTime,
|
|
ProjectionIsCurrent);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copies the currently spatial remote-motion owners into caller-owned
|
|
/// reusable storage. The record reference is the incarnation token: a
|
|
/// delete and GUID reuse cannot make an older snapshot address the newer
|
|
/// object.
|
|
/// </summary>
|
|
internal void CopySpatialRemoteMotionRecordsTo(List<LiveEntityRecord> destination)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(destination);
|
|
destination.Clear();
|
|
_physics.CopySpatialRemotesTo(_spatialRemoteCanonicalScratch);
|
|
for (int index = 0;
|
|
index < _spatialRemoteCanonicalScratch.Count;
|
|
index++)
|
|
{
|
|
RuntimeEntityRecord canonical =
|
|
_spatialRemoteCanonicalScratch[index];
|
|
if (_projections.TryGet(
|
|
canonical,
|
|
out LiveEntityRecord? record)
|
|
&& HasSpatialRuntimeProjection(record))
|
|
{
|
|
destination.Add(record);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal bool IsCurrentSpatialRemoteMotion(
|
|
LiveEntityRecord record,
|
|
IRuntimeRemoteMotion runtime) =>
|
|
IsCurrentRecord(record)
|
|
&& _physics.IsSpatialRemote(record.Canonical, runtime)
|
|
&& HasSpatialRuntimeProjection(record);
|
|
|
|
/// <summary>
|
|
/// Copies only projectile owners that currently have a loaded world-cell
|
|
/// projection. Pending projectiles retain their logical runtime but impose
|
|
/// no per-frame scan cost.
|
|
/// </summary>
|
|
internal void CopySpatialProjectileRecordsTo(List<LiveEntityRecord> destination)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(destination);
|
|
destination.Clear();
|
|
|
|
_physics.CopySpatialProjectilesTo(_spatialRootCanonicalScratch);
|
|
for (int i = 0; i < _spatialRootCanonicalScratch.Count; i++)
|
|
{
|
|
RuntimeEntityRecord canonical = _spatialRootCanonicalScratch[i];
|
|
if (_projections.TryGet(canonical, out LiveEntityRecord? record)
|
|
&& HasSpatialRuntimeProjection(record))
|
|
{
|
|
destination.Add(record);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal bool IsCurrentSpatialProjectile(
|
|
LiveEntityRecord record,
|
|
IRuntimeProjectile runtime) =>
|
|
IsCurrentRecord(record)
|
|
&& _physics.IsSpatialProjectile(record.Canonical, runtime)
|
|
&& HasSpatialRuntimeProjection(record);
|
|
|
|
public bool IsHidden(uint serverGuid) =>
|
|
_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
&& (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0;
|
|
|
|
/// <summary>
|
|
/// Claims the one logical top-level spawn notification for this object
|
|
/// incarnation. Spatial re-entry must restore presentation without replaying
|
|
/// plugin/event registration. An attached-only child claims nothing until it
|
|
/// first becomes a top-level world projection.
|
|
/// </summary>
|
|
public bool TryMarkWorldSpawnPublished(uint serverGuid)
|
|
{
|
|
if (!_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? record)
|
|
|| record.WorldEntity is null
|
|
|| record.ProjectionKind is not LiveEntityProjectionKind.World
|
|
|| record.WorldSpawnPublished)
|
|
return false;
|
|
record.WorldSpawnPublished = true;
|
|
return true;
|
|
}
|
|
|
|
internal bool TryMarkInitialHydrationCompleted(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedCreateIntegrationVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
if (!IsCurrentRecord(expectedRecord)
|
|
|| expectedRecord.CreateIntegrationVersion != expectedCreateIntegrationVersion
|
|
|| expectedRecord.WorldEntity is null
|
|
|| !expectedRecord.ResourcesRegistered)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.InitialHydrationCompleted = true;
|
|
return true;
|
|
}
|
|
|
|
internal bool TryMarkCreateProjectionSynchronizationPending(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedCreateIntegrationVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
if (!IsCurrentCreateIntegration(
|
|
expectedRecord,
|
|
expectedCreateIntegrationVersion))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.CreateProjectionSynchronizationPending = true;
|
|
return true;
|
|
}
|
|
|
|
internal bool TryCompleteCreateProjectionSynchronization(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedCreateIntegrationVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
if (!IsCurrentCreateIntegration(
|
|
expectedRecord,
|
|
expectedCreateIntegrationVersion))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.CreateProjectionSynchronizationPending = false;
|
|
return true;
|
|
}
|
|
|
|
internal bool TryBeginProjectionHydration(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong? expectedCreateIntegrationVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
if (!IsCurrentRecord(expectedRecord)
|
|
|| (expectedCreateIntegrationVersion is { } expected
|
|
&& expectedRecord.CreateIntegrationVersion != expected))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (expectedRecord.ProjectionHydrationInProgress)
|
|
{
|
|
if (expectedRecord.CreateIntegrationVersion
|
|
!= expectedRecord.ProjectionHydrationCreateVersion)
|
|
{
|
|
expectedRecord.ProjectionHydrationRetryRequested = true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.ProjectionHydrationInProgress = true;
|
|
expectedRecord.ProjectionHydrationCreateVersion =
|
|
expectedRecord.CreateIntegrationVersion;
|
|
expectedRecord.ProjectionHydrationRetryRequested = false;
|
|
return true;
|
|
}
|
|
|
|
internal bool EndProjectionHydration(LiveEntityRecord expectedRecord)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
bool retry = IsCurrentRecord(expectedRecord)
|
|
&& (expectedRecord.ProjectionHydrationRetryRequested
|
|
|| expectedRecord.CreateIntegrationVersion
|
|
!= expectedRecord.ProjectionHydrationCreateVersion);
|
|
if (retry)
|
|
{
|
|
// Persist the exact replay obligation at the point version drift
|
|
// is observed. The stale projection body may be unwinding through
|
|
// an exception and therefore never reach the controller's next
|
|
// retry-loop iteration.
|
|
expectedRecord.CreateProjectionSynchronizationPending = true;
|
|
}
|
|
expectedRecord.ProjectionHydrationInProgress = false;
|
|
expectedRecord.ProjectionHydrationCreateVersion = 0UL;
|
|
expectedRecord.ProjectionHydrationRetryRequested = false;
|
|
return retry;
|
|
}
|
|
|
|
internal bool TryBeginAppearanceHydration(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedObjDescAuthorityVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
if (!expectedRecord.AppearanceProjectionSynchronizationPending
|
|
|| !IsCurrentObjDescAuthority(
|
|
expectedRecord,
|
|
expectedObjDescAuthorityVersion))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.AppearanceProjectionSynchronizationPending = true;
|
|
if (expectedRecord.AppearanceHydrationInProgress)
|
|
{
|
|
expectedRecord.AppearanceHydrationRetryRequested = true;
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.AppearanceHydrationInProgress = true;
|
|
expectedRecord.AppearanceHydrationVersion =
|
|
expectedObjDescAuthorityVersion;
|
|
expectedRecord.AppearanceHydrationRetryRequested = false;
|
|
return true;
|
|
}
|
|
|
|
internal bool EndAppearanceHydration(LiveEntityRecord expectedRecord)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
bool retry = IsCurrentRecord(expectedRecord)
|
|
&& (expectedRecord.AppearanceHydrationRetryRequested
|
|
|| expectedRecord.ObjDescAuthorityVersion
|
|
!= expectedRecord.AppearanceHydrationVersion);
|
|
expectedRecord.AppearanceHydrationInProgress = false;
|
|
expectedRecord.AppearanceHydrationVersion = 0UL;
|
|
expectedRecord.AppearanceHydrationRetryRequested = false;
|
|
return retry;
|
|
}
|
|
|
|
internal bool TryCompleteAppearanceProjectionSynchronization(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedObjDescAuthorityVersion)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRecord);
|
|
if (!expectedRecord.AppearanceProjectionSynchronizationPending
|
|
|| !IsCurrentObjDescAuthority(
|
|
expectedRecord,
|
|
expectedObjDescAuthorityVersion))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
expectedRecord.AppearanceProjectionSynchronizationPending = false;
|
|
return true;
|
|
}
|
|
|
|
internal bool IsCurrentCreateIntegration(
|
|
LiveEntityRecord expectedRecord,
|
|
ulong expectedCreateIntegrationVersion) =>
|
|
IsCurrentRecord(expectedRecord)
|
|
&& expectedRecord.CreateIntegrationVersion == expectedCreateIntegrationVersion;
|
|
|
|
/// <summary>
|
|
/// Retires only the graphical sidecar for an exact Runtime-owned
|
|
/// incarnation. The Runtime generation-reset transaction retains and
|
|
/// completes the canonical tombstone after this acknowledgement returns.
|
|
/// </summary>
|
|
internal void RetireGenerationProjection(
|
|
RuntimeEntityRecord canonical)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(canonical);
|
|
if (_isClearing || _isRegisteringResources)
|
|
{
|
|
throw new InvalidOperationException(
|
|
_isClearing
|
|
? "Live entity projection teardown is already in progress."
|
|
: "Live entity projection teardown cannot begin inside atomic resource registration.");
|
|
}
|
|
|
|
LiveEntityRecord? record = null;
|
|
if (_projections.TryGet(canonical, out LiveEntityRecord active))
|
|
{
|
|
if (!_projections.RemoveActive(active))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Exact App projection for 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} could not be retired during generation reset.");
|
|
}
|
|
_projections.RetainTeardown(active);
|
|
record = active;
|
|
}
|
|
else if (_projections.TryGetTeardown(
|
|
canonical,
|
|
out LiveEntityRecord retained))
|
|
{
|
|
record = retained;
|
|
}
|
|
|
|
if (record is null)
|
|
return;
|
|
|
|
_isClearing = true;
|
|
try
|
|
{
|
|
TearDownRecord(record, completeCanonical: false);
|
|
_projections.ReleaseTeardown(record);
|
|
}
|
|
finally
|
|
{
|
|
_isClearing = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Completes the App-only projection boundary after Runtime has retired
|
|
/// every exact canonical incarnation. No canonical owner is mutated here.
|
|
/// </summary>
|
|
internal void CompleteGenerationProjectionRetirement()
|
|
{
|
|
if (_projections.ActiveCount != 0
|
|
|| _projections.TeardownCount != 0)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Live entity projection storage has not converged at the generation-reset boundary.");
|
|
}
|
|
|
|
_spatialAnimations.Clear();
|
|
_physics.ClearSpatialWorksets();
|
|
_projections.ClearConverged();
|
|
_spatial.ClearLiveEntityLifetimeState();
|
|
_sessionClearPendingFinalization = false;
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
if (_isClearing || _isRegisteringResources)
|
|
{
|
|
throw new InvalidOperationException(
|
|
_isClearing
|
|
? "Live entity session teardown is already in progress."
|
|
: "Live entity session teardown cannot begin inside atomic resource registration.");
|
|
}
|
|
|
|
_isClearing = true;
|
|
_sessionClearPendingFinalization = true;
|
|
IReadOnlyList<RuntimeEntityRecord> retiredCanonicals =
|
|
_entityObjects.BeginSessionClear();
|
|
List<Exception>? failures = null;
|
|
try
|
|
{
|
|
foreach (RuntimeEntityRecord canonical in retiredCanonicals)
|
|
{
|
|
if (_projections.TryGet(
|
|
canonical,
|
|
out LiveEntityRecord? record))
|
|
{
|
|
if (!_projections.RemoveActive(record))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Exact App projection for 0x{record.ServerGuid:X8}/{record.Generation} could not be retired during session clear.");
|
|
}
|
|
RetainTeardownRecord(record);
|
|
}
|
|
else
|
|
{
|
|
TearDownCanonicalOnly(canonical);
|
|
}
|
|
}
|
|
|
|
foreach (LiveEntityRecord record in _projections.TeardownRecords.ToArray())
|
|
{
|
|
if (!_projections.IsRetainedTeardown(record))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// A re-entrant Clear can observe the tombstone currently being
|
|
// unregistered by its caller. Leave it queued; the outer
|
|
// teardown's ReleaseTeardownRecord finalizes the session once
|
|
// it unwinds successfully.
|
|
if (record.TeardownInProgress)
|
|
continue;
|
|
try
|
|
{
|
|
TearDownRecord(record);
|
|
ReleaseTeardownRecord(record);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
}
|
|
}
|
|
|
|
CompleteSessionClearIfConverged();
|
|
}
|
|
finally
|
|
{
|
|
_isClearing = false;
|
|
}
|
|
|
|
if (failures is not null)
|
|
throw new AggregateException("One or more live entities failed session teardown.", failures);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drains teardown tombstones whose first unregister attempt failed. This
|
|
/// runs on the update thread after the callback stack that deferred the
|
|
/// removal has unwound; successful steps are not replayed.
|
|
/// </summary>
|
|
public int RetryPendingTeardowns()
|
|
{
|
|
if (_projections.TeardownCount == 0
|
|
|| _isClearing
|
|
|| _isRegisteringResources
|
|
|| _logicalTeardownDepth != 0)
|
|
return 0;
|
|
|
|
int completed = 0;
|
|
List<Exception>? failures = null;
|
|
foreach (LiveEntityRecord record in _projections.TeardownRecords.ToArray())
|
|
{
|
|
if (!_projections.IsRetainedTeardown(record))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (record.TeardownInProgress)
|
|
continue;
|
|
|
|
_logicalTeardownDepth++;
|
|
try
|
|
{
|
|
try
|
|
{
|
|
TearDownRecord(record);
|
|
ReleaseTeardownRecord(record);
|
|
completed++;
|
|
if (!_directory.TryGetActive(record.ServerGuid, out _)
|
|
&& !HasPendingTeardown(record.ServerGuid)
|
|
&& !_directory.TryGetSnapshot(record.ServerGuid, out _))
|
|
{
|
|
_spatial.ForgetLiveEntity(record.ServerGuid);
|
|
}
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_logicalTeardownDepth--;
|
|
}
|
|
}
|
|
|
|
if (failures is not null)
|
|
throw new AggregateException("One or more pending live-entity teardowns failed again.", failures);
|
|
return completed;
|
|
}
|
|
|
|
private void RefreshRecord(
|
|
uint guid,
|
|
WorldSession.EntitySpawn accepted,
|
|
bool refreshPosition = false)
|
|
{
|
|
if (!_directory.TryGetActive(guid, out RuntimeEntityRecord canonical))
|
|
return;
|
|
_directory.RefreshSnapshot(canonical, accepted, refreshPosition);
|
|
}
|
|
|
|
private ulong AdvanceLifetimeMutation(uint serverGuid)
|
|
=> _directory.AdvanceLifetimeMutation(serverGuid);
|
|
|
|
private ulong CurrentLifetimeMutation(uint serverGuid) =>
|
|
_directory.CurrentLifetimeMutation(serverGuid);
|
|
|
|
private static RuntimeEntityKey RequireProjectionKey(
|
|
LiveEntityRecord record) =>
|
|
record.ProjectionKey ?? record.Canonical.Key
|
|
?? throw new InvalidOperationException(
|
|
$"Live entity 0x{record.ServerGuid:X8}/{record.Generation} has no materialized projection key.");
|
|
|
|
private bool IsCurrentProjectionOperation(
|
|
uint serverGuid,
|
|
LiveEntityRecord record,
|
|
ulong projectionOperation) =>
|
|
_projections.TryGetCurrent(serverGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record)
|
|
&& record.ProjectionMutationVersion == projectionOperation;
|
|
|
|
private static void ThrowAfterCommittedProjectionChange(
|
|
uint serverGuid,
|
|
Exception? spatialNotificationFailure,
|
|
Exception? runtimeNotificationFailure)
|
|
{
|
|
if (spatialNotificationFailure is not null
|
|
&& runtimeNotificationFailure is not null)
|
|
{
|
|
throw new AggregateException(
|
|
$"Projection change for live entity 0x{serverGuid:X8} committed, but spatial and runtime observers failed.",
|
|
spatialNotificationFailure,
|
|
runtimeNotificationFailure);
|
|
}
|
|
|
|
Exception? failure = spatialNotificationFailure ?? runtimeNotificationFailure;
|
|
if (failure is not null)
|
|
ExceptionDispatchInfo.Capture(failure).Throw();
|
|
}
|
|
|
|
private void AcknowledgeCelllessCanonicalCommit(
|
|
RuntimeEntityRecord canonical)
|
|
{
|
|
if (!_directory.IsCurrent(canonical)
|
|
|| !_projections.TryGet(
|
|
canonical,
|
|
out LiveEntityRecord? record))
|
|
{
|
|
return;
|
|
}
|
|
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
}
|
|
|
|
internal bool IsCurrentRecord(LiveEntityRecord record) =>
|
|
_projections.TryGetCurrent(record.ServerGuid, out LiveEntityRecord? current)
|
|
&& ReferenceEquals(current, record);
|
|
|
|
private static bool HasSpatialRuntimeProjection(LiveEntityRecord record) =>
|
|
record.WorldEntity is not null
|
|
&& record.ProjectionKind is LiveEntityProjectionKind.World
|
|
&& record.IsSpatiallyProjected
|
|
&& record.IsSpatiallyVisible
|
|
&& record.FullCellId != 0;
|
|
|
|
private void RefreshSpatialRuntimeIndexes(LiveEntityRecord record)
|
|
{
|
|
bool current = IsCurrentRecord(record);
|
|
bool spatial = current && HasSpatialRuntimeProjection(record);
|
|
if (record.ProjectionKey is not { } key)
|
|
{
|
|
if (record.WorldEntity is not null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Materialized live entity 0x{record.ServerGuid:X8}/{record.Generation} has no exact projection key.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
_physics.AcknowledgeSpatialProjection(record.Canonical, spatial);
|
|
|
|
RefreshSpatialPresentationIndexes(record, current, spatial, key);
|
|
}
|
|
|
|
private void RefreshSpatialPresentationIndexes(LiveEntityRecord record)
|
|
{
|
|
bool current = IsCurrentRecord(record);
|
|
bool spatial = current && HasSpatialRuntimeProjection(record);
|
|
if (record.ProjectionKey is not { } key)
|
|
{
|
|
if (record.WorldEntity is not null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Materialized live entity 0x{record.ServerGuid:X8}/{record.Generation} has no exact projection key.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
RefreshSpatialPresentationIndexes(record, current, spatial, key);
|
|
}
|
|
|
|
private void RefreshSpatialPresentationIndexes(
|
|
LiveEntityRecord record,
|
|
bool current,
|
|
bool spatial,
|
|
RuntimeEntityKey key)
|
|
{
|
|
|
|
if (record.WorldEntity is not null)
|
|
{
|
|
if (spatial && record.AnimationRuntime is { } animation)
|
|
{
|
|
_spatialAnimations[key] = animation;
|
|
}
|
|
else if (current
|
|
|| (record.AnimationRuntime is { } retainedAnimation
|
|
&& _spatialAnimations.TryGetValue(key, out var indexedAnimation)
|
|
&& ReferenceEquals(indexedAnimation, retainedAnimation)))
|
|
{
|
|
_spatialAnimations.Remove(key);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
private void RemoveSpatialRuntimeIndexes(LiveEntityRecord record)
|
|
{
|
|
if (record.ProjectionKey is not { } key)
|
|
return;
|
|
|
|
_physics.RemoveSpatialProjection(record.Canonical);
|
|
|
|
if (record.WorldEntity is not null
|
|
&& _spatialAnimations.TryGetValue(key, out var indexedAnimation)
|
|
&& ReferenceEquals(indexedAnimation, record.AnimationRuntime))
|
|
{
|
|
_spatialAnimations.Remove(key);
|
|
}
|
|
|
|
}
|
|
|
|
private void OnSpatialVisibilityChanged(RuntimeEntityKey key, bool visible)
|
|
{
|
|
// GpuWorldState serializes re-entrant visibility callbacks. A callback
|
|
// may delete an incarnation and materialize the same server GUID before
|
|
// an older queued edge drains. Apply an edge only while it still
|
|
// matches current spatial truth; otherwise it belongs to the displaced
|
|
// projection and must not mutate the replacement generation.
|
|
if (_spatial.IsLiveEntityProjectionResident(key) != visible)
|
|
return;
|
|
if (!_projections.TryGet(
|
|
key,
|
|
out LiveEntityRecord? record)
|
|
|| record.WorldEntity is not { } entity
|
|
|| entity.Id != key.LocalEntityId)
|
|
return;
|
|
|
|
uint serverGuid = record.ServerGuid;
|
|
bool wasVisible = record.IsSpatiallyVisible;
|
|
if (RequireProjectionKey(record) != key)
|
|
return;
|
|
if (_presentationOnlySpatialMutationDepth.ContainsKey(key))
|
|
{
|
|
record.IsSpatiallyVisible = visible;
|
|
RefreshSpatialPresentationIndexes(record);
|
|
RefreshPresentation(record);
|
|
return;
|
|
}
|
|
bool wasOrdinaryRoot = _physics.IsSpatialRoot(record.Canonical);
|
|
record.IsSpatiallyVisible = visible;
|
|
bool isOrdinaryRoot = record.ProjectionKind is LiveEntityProjectionKind.World
|
|
&& record.IsSpatiallyProjected
|
|
&& visible
|
|
&& record.FullCellId != 0;
|
|
if (_rebucketingGuid != serverGuid && wasOrdinaryRoot != isOrdinaryRoot)
|
|
{
|
|
if (isOrdinaryRoot)
|
|
record.ResetObjectClockForEnterWorld(
|
|
(record.FinalPhysicsState & PhysicsStateFlags.Static) != 0);
|
|
else
|
|
record.SuspendObjectClock();
|
|
SynchronizePhysicsBodyActiveState(record);
|
|
}
|
|
RefreshSpatialRuntimeIndexes(record);
|
|
RefreshPresentation(record);
|
|
if (_rebucketingGuid != serverGuid && wasVisible != visible)
|
|
PublishProjectionVisibilityChanged(record, visible);
|
|
}
|
|
|
|
private void OnRuntimePhysicsCellCommitted(
|
|
RuntimePhysicsCellCommit commit)
|
|
{
|
|
if (commit.Record.SpatialAuthorityVersion
|
|
!= commit.SpatialAuthorityVersion
|
|
|| !_directory.IsCurrent(commit.Record)
|
|
|| !_projections.TryGet(
|
|
commit.Record,
|
|
out LiveEntityRecord? projection)
|
|
|| projection.WorldEntity is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
RebucketLiveEntity(
|
|
commit.Record.ServerGuid,
|
|
commit.FullCellId);
|
|
}
|
|
|
|
private static void SynchronizePhysicsBodyActiveState(
|
|
LiveEntityRecord record)
|
|
{
|
|
if (record.PhysicsBody is not { } body)
|
|
return;
|
|
if (record.ObjectClock.IsActive)
|
|
body.TransientState |= TransientStateFlags.Active;
|
|
else
|
|
body.TransientState &= ~TransientStateFlags.Active;
|
|
}
|
|
|
|
private void PublishProjectionVisibilityChanged(LiveEntityRecord record, bool visible)
|
|
{
|
|
Delegate[] subscribers = ProjectionVisibilityChanged?.GetInvocationList()
|
|
?? Array.Empty<Delegate>();
|
|
List<Exception>? failures = null;
|
|
for (int i = 0; i < subscribers.Length; i++)
|
|
{
|
|
try
|
|
{
|
|
((Action<LiveEntityRecord, bool>)subscribers[i])(record, visible);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
}
|
|
}
|
|
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
$"One or more projection observers failed for live entity 0x{record.ServerGuid:X8}.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
private void RefreshPresentation(LiveEntityRecord record)
|
|
{
|
|
if (record.WorldEntity is not { } entity)
|
|
return;
|
|
|
|
PhysicsStateFlags state = record.FinalPhysicsState;
|
|
bool residenceVisible = record.MaterializationResidence is not
|
|
LiveEntityMaterializationResidence.AwaitRuntimePlacement
|
|
|| record.IsSpatiallyProjected;
|
|
entity.IsDrawVisible = residenceVisible
|
|
&& (state & (PhysicsStateFlags.NoDraw | PhysicsStateFlags.Hidden)) == 0;
|
|
|
|
bool interactionVisible = record.IsSpatiallyVisible
|
|
&& record.ProjectionKind is LiveEntityProjectionKind.World
|
|
&& (state & PhysicsStateFlags.Hidden) == 0;
|
|
if (interactionVisible)
|
|
_projections.SetVisible(record, true);
|
|
else
|
|
_projections.SetVisible(record, false);
|
|
}
|
|
|
|
private void RetainTeardownRecord(LiveEntityRecord record)
|
|
{
|
|
_directory.RetainTeardown(record.Canonical);
|
|
_projections.RetainTeardown(record);
|
|
}
|
|
|
|
private Exception? RetirePriorProjection(
|
|
RuntimeEntityRecord canonical)
|
|
{
|
|
if (!_projections.TryGet(
|
|
canonical,
|
|
out LiveEntityRecord? projection))
|
|
{
|
|
return _entityObjects.RetireCanonicalOnly(canonical);
|
|
}
|
|
|
|
if (!_projections.RemoveActive(projection))
|
|
{
|
|
return new InvalidOperationException(
|
|
$"Exact App projection for 0x{canonical.ServerGuid:X8}/{canonical.Incarnation} could not be retired.");
|
|
}
|
|
|
|
RetainTeardownRecord(projection);
|
|
_logicalTeardownDepth++;
|
|
try
|
|
{
|
|
try
|
|
{
|
|
TearDownRecord(projection);
|
|
ReleaseTeardownRecord(projection);
|
|
return null;
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
return error;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_logicalTeardownDepth--;
|
|
}
|
|
}
|
|
|
|
private void TearDownCanonicalOnly(RuntimeEntityRecord canonical)
|
|
{
|
|
Exception? failure = _entityObjects.RetireCanonicalOnly(canonical);
|
|
if (failure is not null)
|
|
ExceptionDispatchInfo.Capture(failure).Throw();
|
|
}
|
|
|
|
private void ReleaseTeardownRecord(LiveEntityRecord record)
|
|
{
|
|
_projections.ReleaseTeardown(record);
|
|
_directory.ReleaseTeardown(record.Canonical);
|
|
CompleteSessionClearIfConverged();
|
|
}
|
|
|
|
private bool HasPendingTeardown(uint serverGuid) =>
|
|
_directory.HasPendingTeardown(serverGuid);
|
|
|
|
private void CompleteSessionClearIfConverged()
|
|
{
|
|
if (!_sessionClearPendingFinalization
|
|
|| !_entityObjects.CompleteSessionClearIfConverged())
|
|
{
|
|
return;
|
|
}
|
|
|
|
_spatialAnimations.Clear();
|
|
_physics.ClearSpatialWorksets();
|
|
_projections.ClearConverged();
|
|
_spatial.ClearLiveEntityLifetimeState();
|
|
_sessionClearPendingFinalization = false;
|
|
}
|
|
|
|
private void TearDownRecord(
|
|
LiveEntityRecord record,
|
|
bool completeCanonical = true)
|
|
{
|
|
if (record.TeardownInProgress)
|
|
throw new InvalidOperationException(
|
|
$"Live entity 0x{record.ServerGuid:X8} teardown is already in progress.");
|
|
|
|
record.TeardownInProgress = true;
|
|
List<Exception>? failures = null;
|
|
bool TryCleanup(Action cleanup)
|
|
{
|
|
try
|
|
{
|
|
cleanup();
|
|
return true;
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= new List<Exception>()).Add(error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
// Remove frame-workset membership before arbitrary callbacks. The
|
|
// logical component dictionaries and WorldEntity remain intact as
|
|
// a retryable tombstone until every external cleanup acknowledges
|
|
// success.
|
|
RemoveSpatialRuntimeIndexes(record);
|
|
_physics.ClearProjectile(record.Canonical);
|
|
|
|
if (!record.RuntimeComponentsTeardownCompleted)
|
|
{
|
|
record.RuntimeComponentsTeardownCompleted =
|
|
TryCleanup(() => _runtimeComponentLifecycle.TearDown(record));
|
|
}
|
|
|
|
if (record.WorldEntity is { } entity)
|
|
{
|
|
if (!record.SpatialProjectionTeardownCompleted)
|
|
{
|
|
record.SpatialProjectionTeardownCompleted =
|
|
TryCleanup(() => _spatial.RemoveLiveEntityProjection(entity));
|
|
}
|
|
if (record.ResourcesRegistered
|
|
&& TryCleanup(() => _resources.Unregister(entity)))
|
|
{
|
|
record.ResourcesRegistered = false;
|
|
}
|
|
}
|
|
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
$"Live entity 0x{record.ServerGuid:X8} teardown failed.",
|
|
failures);
|
|
}
|
|
|
|
if (record.WorldEntity is not null)
|
|
_ = RequireProjectionKey(record);
|
|
if (completeCanonical)
|
|
{
|
|
_entityObjects.CompleteProjectionRetirement(
|
|
record.Canonical);
|
|
}
|
|
|
|
record.AnimationRuntime = null;
|
|
record.EffectProfile = null;
|
|
record.IsSpatiallyProjected = false;
|
|
record.IsSpatiallyVisible = false;
|
|
record.WorldSpawnPublished = false;
|
|
record.InitialHydrationCompleted = false;
|
|
record.CreateProjectionSynchronizationPending = false;
|
|
record.ProjectionHydrationInProgress = false;
|
|
record.ProjectionHydrationCreateVersion = 0UL;
|
|
record.ProjectionHydrationRetryRequested = false;
|
|
record.AppearanceProjectionSynchronizationPending = false;
|
|
record.AppearanceHydrationInProgress = false;
|
|
record.AppearanceHydrationVersion = 0UL;
|
|
record.AppearanceHydrationRetryRequested = false;
|
|
record.WorldEntity = null;
|
|
}
|
|
finally
|
|
{
|
|
record.TeardownInProgress = false;
|
|
}
|
|
}
|
|
|
|
}
|