Retail re-cells children when their parent crosses a cell, recursively, to unbounded depth. acdream did it from a RENDER tick, so headless parented children were cell-less forever and the canonical cell had two writers. This slice makes Runtime the sole authority and demotes App's tick to presentation-only. Contract: docs/research/2026-08-04-c4-route-7-contract.md; the research that unblocked it is docs/research/2026-08-04-retail-parent-cell-propagation.md (ca96ea5e). Retail: SetPositionInternal @0x00515330 branches on `this->cell == curr_cell` @0x0051536d; the changed branch reaches change_cell @0x00513390, whose delegates leave_cell @0x00510f50 and enter_cell @0x00510ed0 self-recurse over children and write the FULL identity (add_object @0x00510ee2, objcell_id @0x00510f1e, part-array cell id @0x00510f2b, cell pointer @0x00510f35). change_cell itself has no child loop. THE TRAP, recorded because it nearly shipped: the depth-1 loop @0x0051539c-0x005153d8 is the SAME-CELL fast path (objcell_id and part-array id only, deliberately not the cell pointer), NOT the propagation. An implementer who finds it first concludes "depth-1, id-only" and strands every equipped item at a landblock boundary — the #184 class. The clincher against that reading: update_object @0x00515d10 early-returns on `parent != 0` @0x00515d40, so a child never runs its own physics tick and parent propagation is the ONLY mechanism maintaining its cell. Route 7 performs NO placement (DoPickupEvent @0x00452240 = unset_parent + leave_world; DoParentEvent @0x00452290 = set_parent + SetPlacementFrame), so it arms ConstrainTo nowhere — the leash rule INVERTS relative to routes 2/4/5, and both reviewers confirmed nothing arms. Propagation is an ITERATIVE WORKLIST, not recursion. The first implementation recursed with a depth-64 cap; both reviews independently found the cap left a truncated tail at a stale NON-ZERO cell — permanently unrecoverable, logged only under a probe flag, and on the withdraw path exactly the #184 shape AP-142 clause (a) exists to reject. Shipping a fresh #184 instance inside the slice that fixes stranded children was not acceptable, so the cap was removed rather than tuned. The worklist retires the cap, the constant, its register clause, and the failure mode together. Termination: every record on the stack is already at the target pair, so nothing can be pushed twice and a hostile A->B->A cycle collapses without a visited set. The child write deliberately bypasses the public RuntimeEntityDirectory .SetFullCell and calls the record method directly. This is LOAD-BEARING: the public method re-enters PropagateFullCellToChildren, which opens with _propagationWorklist.Clear() — routing children through it mid-drain would wipe the shared stack and silently drop every unprocessed sibling. Any future side effect added to the public SetFullCell must be mirrored by hand at that call site. Deliberate divergence, recorded not disguised: retail's removal path leaves children with a null cell pointer but a STALE nonzero objcell_id @0x005133c1. acdream does not reproduce it, because FullCellId != 0 is the liveness predicate at 45+ sites — faithful porting would mark dead children live. AP-142 records this; clause (d) records that acdream cannot gate propagation on HasPartArray the way enter_cell gates on part_array @0x00510ed8, because the flag's only writers are graphical and headless never sets it — the reason is Slice J LAYERING, not a semantic difference (retail's part_array is itself a mesh-construction product, single assignment site makeAnimObject @0x0050e930 -> CPartArray::CreateSetup @0x0050e93e). D7 adopts retail's unset_parent-before-leave_world order @0x0045227f -> @0x00452286, applied to BOTH pickup paths including the dormant executor replay. Its inertness was verified by reverting it and finding all 12 propagation tests still green — reported honestly rather than papered over with a manufactured test, and independently confirmed by both reviewers. ClassifyLeaveWorld and its request/cause types are DELETED: retail has no classification here, and method-per-cause IS the retail dispatch shape. Wiring it would have forced a vacuous teleport-sequence predicate with the #307 shape. Two review rounds plus a coordinator-required third pass; 5 MAJORs. One was a handoff failure worth recording: enter_cell's part_array guard was correctly identified as load-bearing by the research, dropped by the contract when it enumerated the writes, and inherited as an omission by the code — a right finding that evaporated across two handoffs with nobody re-reading the source. Another was a test that survived deleting the entire behaviour it claimed to pin, because its assertion read a field written unconditionally one line earlier. NoProjection is structurally unreachable from TickChild (TryResolveExactAttachment performs a strictly stronger form of the same guard one call earlier). Kept as a fail-safe, unit-tested directly, and documented in two places rather than wrapped in a fabricated end-to-end test. Headless regression test — the direct gate for this defect, which FAILED before this work because no code path existed: RuntimeLiveEntitySessionControllerTests .DirectSink_D5_StandaloneParentEventCommitsChildToParentsExactCell. Probe: ACDREAM_PROBE_CHILD_CELL=1 emits [child-cell] lines at all four write sites (attach / headless-attach / propagate / withdraw / delete). TEMPORARY. Complete Release suite MEASURED at 11,079 passed / 4 skipped / 0 failed (baseline 11,063 atcff52c44, +16). An allocation flake appeared once under load and was proven NOT this slice by reachability — RuntimeCollisionReportingState contains zero SetFullCell and zero ParentAttachments references. STILL OWED: the two-client connected gate (equip/unequip, carry across landblock boundaries, pickup, loot, reconnect) with ACDREAM_PROBE_CHILD_CELL=1, and a session counts only if [child-cell] cause=propagate lines appear. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2589 lines
126 KiB
C#
2589 lines
126 KiB
C#
using System.Collections.Immutable;
|
|
using System.Numerics;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Runtime.Physics;
|
|
|
|
namespace AcDream.Runtime.Entities;
|
|
|
|
internal enum RuntimeInitialCreateExecutionStatus : byte
|
|
{
|
|
/// <summary>Initial tail + entire FIFO revision applied + residence consumed.</summary>
|
|
Completed,
|
|
/// <summary>Initial authored placement not yet acknowledged; retry later.</summary>
|
|
PendingPlacement,
|
|
/// <summary>
|
|
/// Yielded mid-drain: a Position continuation began an authored placement
|
|
/// that is not yet acknowledged; retry later. Two distinct flavors share
|
|
/// this one status (Round 4 R4-14): (1) the ORDINARY flavor, where
|
|
/// <see cref="RuntimeInitialCreateContinuationExecutor.TryGetPendingContinuationPlacement"/>
|
|
/// returns the exact token to drive prepare/submit/acknowledge on; and
|
|
/// (2) transient operation-slot CONTENTION (Round 3 B1) - another
|
|
/// operation already occupies this entity's SetPosition slot at the
|
|
/// moment the continuation's own merge committed. In flavor (2),
|
|
/// <c>TryGetPendingContinuationPlacement</c> returns <c>false</c> (no
|
|
/// token was ever begun) even though the overall status is still
|
|
/// AwaitingContinuationPlacement; the caller's only correct action is to
|
|
/// retry <c>Execute</c> again later with no placement work of its own -
|
|
/// the retry re-attempts ONLY the placement begin against the
|
|
/// already-committed merge, never re-running the merge or re-publishing.
|
|
/// </summary>
|
|
AwaitingContinuationPlacement,
|
|
RejectedToken,
|
|
/// <summary>Residence retired/superseded - abandoned, ledgers converged.</summary>
|
|
RejectedAuthority,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Executor-time inputs sampled at the retail decision point. These cannot be
|
|
/// retained at admission time because they describe LIVE state (the local
|
|
/// player, the current physics simulation) rather than the accepted wire
|
|
/// packet itself. Round 3 A3: contact is NOT one of these - it comes solely
|
|
/// from the retained wire packet's own <c>IsGrounded</c> bit (PositionPack
|
|
/// bit 0x4, server-asserted contact at admission time), never from a live
|
|
/// body query or a caller-supplied fallback.
|
|
/// </summary>
|
|
internal readonly record struct RuntimeInitialCreateExecutionInputs(
|
|
bool UsePositionFromServer,
|
|
float PlayerDistance);
|
|
|
|
internal enum RuntimeInitialCreateExecutedActionKind : byte
|
|
{
|
|
InitialAdoption,
|
|
TeleportHookRequest,
|
|
DeferredChildReplay,
|
|
/// <summary>Round 5 R5-1: one queued accepted parent relation replayed in this parent's own initial tail.</summary>
|
|
ParentRelationReplay,
|
|
PreTailDescriptionAdaptation,
|
|
ObjDesc,
|
|
CreateParent,
|
|
Parent,
|
|
Pickup,
|
|
Position,
|
|
Movement,
|
|
State,
|
|
Vector,
|
|
WeenieDescription,
|
|
ResidentCellCleanup,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 4 R4-6: outcome of one deferred child's replay registration
|
|
/// (<see cref="RuntimeInitialCreateExecutedActionKind.DeferredChildReplay"/>).
|
|
/// Replaces the previous <c>bool DeferredChildRegistered</c> field, which
|
|
/// collapsed a genuine re-defer (the grandparent is ALSO still missing)
|
|
/// into the same "true" value as an outright successful registration.
|
|
/// </summary>
|
|
internal enum RuntimeDeferredChildReplayOutcome : byte
|
|
{
|
|
/// <summary><see cref="RuntimeEntityRegistrationResult.Canonical"/> was non-null.</summary>
|
|
Registered,
|
|
/// <summary><see cref="RuntimeEntityRegistrationResult.DeferredForParent"/> was true - the replayed child itself still has a missing (grand)parent.</summary>
|
|
ReDeferred,
|
|
/// <summary>Neither Canonical nor DeferredForParent - registration was rejected outright, or the registration callback threw (Round 4 R4-1).</summary>
|
|
Rejected,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 4 R4-6: distinct outcome for a Parent/CreateParent relation
|
|
/// applied at execution time. Replaces the Round 3 B9 dead-letter
|
|
/// re-Enqueue with retail-faithful discard (Round 4 R4-5).
|
|
/// </summary>
|
|
internal enum RuntimeParentRelationOutcome : byte
|
|
{
|
|
/// <summary>The parent was addressable and current (or, at replay, named the exact live incarnation); the attach commit ran.</summary>
|
|
Applied,
|
|
/// <summary>
|
|
/// Round 5 R5-1: the LIVE parent incarnation is newer than the one this
|
|
/// relation named - retail-faithful discard, mirroring
|
|
/// <see cref="ParentAttachmentState.Resolve"/>'s own "current parent
|
|
/// newer than the packet" branch. The already-accepted position-
|
|
/// timestamp merge already ran (at the relation's original drain, not
|
|
/// repeated here); no leave-world, no placement forget.
|
|
/// </summary>
|
|
DiscardedStaleParent,
|
|
/// <summary>
|
|
/// Round 5 R5-1: the parent is unaddressable, or (standalone Parent
|
|
/// only) names a parent incarnation that has not yet arrived - queued
|
|
/// under the parent's guid exactly like retail's <c>QueueBlobForObject</c>
|
|
/// (pseudo-C 92326), replayed when that guid is created.
|
|
/// </summary>
|
|
DeferredAwaitingParent,
|
|
/// <summary>
|
|
/// Round 5 R5-3: the queued relation's child is no longer valid at
|
|
/// replay time (not current, or a different incarnation than when
|
|
/// queued) - a contained failure, not an exception; recorded and
|
|
/// skipped, never resurrected.
|
|
/// </summary>
|
|
Rejected,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail's exact three-way ResidentCellCleanup disposition (retail-notes.md
|
|
/// function 1, SmartBox::HandleCreateObject 0x00454c80, lines ~788-801).
|
|
/// </summary>
|
|
internal enum RuntimeResidentCellCleanupDisposition : byte
|
|
{
|
|
/// <summary>
|
|
/// <c>objcell_id != 0 && cell != 0</c>: already resident -
|
|
/// un-mark (RemoveObjectToBeDestroyed).
|
|
/// </summary>
|
|
ResidentUnmarked,
|
|
/// <summary>
|
|
/// <c>objcell_id != 0 && cell == 0</c> while an existing
|
|
/// lost-cell/deferred SetPosition operation already owns this exact
|
|
/// entity: the destruction mark belongs to that existing lifetime, not
|
|
/// to this tail action.
|
|
/// </summary>
|
|
DeferredUnderLostCellOwnership,
|
|
/// <summary>
|
|
/// No cell claimed at all (<c>objcell_id == 0</c>). Retail's own third
|
|
/// case (<c>HandleCreateObject</c>, retail-notes.md function 1, lines
|
|
/// ~93942-93943) additionally requires NO weenie description before
|
|
/// marking for destruction. That second half is structurally
|
|
/// UNREACHABLE through this exact envelope path: every
|
|
/// <c>SameIncarnationCreate</c> continuation this codebase constructs
|
|
/// carries a WeenieDescription action immediately before
|
|
/// ResidentCellCleanup, never optionally
|
|
/// (<see cref="RuntimeInitialCreateResidenceContinuation.HasValidShape"/>,
|
|
/// <c>RuntimeInitialCreateResidenceState.cs:277-284</c>, enforces
|
|
/// <c>Actions[^2].Kind is WeenieDescription</c> for every admitted
|
|
/// envelope). This value records the conservative claimed-but-celless
|
|
/// fact for that case without asserting it matches retail's documented
|
|
/// no-weenie destruction mark, and without building a second destruction
|
|
/// mechanism ahead of the object-table wiring that would let the
|
|
/// executor distinguish the two.
|
|
/// </summary>
|
|
CelllessNoWeenieMarkUnreachable,
|
|
}
|
|
|
|
/// <summary>
|
|
/// One immutable trace entry. <see cref="Sequence"/> is the owning
|
|
/// continuation's FIFO sequence (0 for initial-tail-only facts that precede
|
|
/// the FIFO entirely). <see cref="Stage"/> is the same-incarnation envelope
|
|
/// action index, or -1 outside an envelope. <see cref="PositionDisposition"/>
|
|
/// and <see cref="HookPhase"/> are only meaningful for Position/hook-request
|
|
/// entries; <see cref="ResidentCellCleanupDisposition"/> only for
|
|
/// <see cref="RuntimeInitialCreateExecutedActionKind.ResidentCellCleanup"/>.
|
|
/// </summary>
|
|
internal readonly record struct RuntimeInitialCreateExecutedAction(
|
|
RuntimeInitialCreateExecutedActionKind Kind,
|
|
ulong Sequence,
|
|
int Stage,
|
|
RuntimeAuthoritativePositionDisposition? PositionDisposition,
|
|
RuntimeTeleportHookPhase HookPhase,
|
|
RuntimeDeferredChildReplayOutcome? DeferredChildOutcome = null,
|
|
RuntimeResidentCellCleanupDisposition? ResidentCellCleanupDisposition = null,
|
|
RuntimePositionConstrainPhase ConstrainPhase = RuntimePositionConstrainPhase.None,
|
|
bool StopInterpolating = false,
|
|
bool ZeroVelocity = false,
|
|
bool PreserveHeading = false,
|
|
bool SendPositionImmediately = false,
|
|
bool UnparentBeforeRouting = false,
|
|
RuntimeParentRelationOutcome? ParentRelationOutcome = null);
|
|
|
|
/// <summary>
|
|
/// Host-independent immutable execution result. Hosts/tests consume this; the
|
|
/// executor never calls presentation.
|
|
/// </summary>
|
|
internal readonly record struct RuntimeInitialCreateExecutionReceipt(
|
|
RuntimeEntityKey Entity,
|
|
uint FullCellId,
|
|
RuntimeTeleportHookPhase TeleportHookPhase,
|
|
ImmutableArray<RuntimeInitialCreateExecutedAction> Trace,
|
|
int ReplayedDeferredChildCount);
|
|
|
|
/// <summary>
|
|
/// C3-1: public projection of <see cref="RuntimeTeleportHookPhase"/>. A
|
|
/// separate public enum (rather than widening the internal one's
|
|
/// accessibility) keeps the classifier/executor's internal vocabulary free to
|
|
/// evolve without becoming a host-facing contract; values map 1:1 today.
|
|
/// </summary>
|
|
public enum RuntimeInitialCreateTeleportHookPhase : byte
|
|
{
|
|
None,
|
|
BeforePositionOperation,
|
|
AfterPositionOperation,
|
|
AfterEnterWorld,
|
|
}
|
|
|
|
/// <summary>C3-1: public projection of <see cref="RuntimeAuthoritativePositionDisposition"/>.</summary>
|
|
public enum RuntimeInitialCreatePositionDisposition : byte
|
|
{
|
|
RejectedAuthority,
|
|
RejectedData,
|
|
AwaitFreshPosition,
|
|
NoPositionOperation,
|
|
Interpolate,
|
|
SetPosition,
|
|
SetPositionSimple,
|
|
}
|
|
|
|
/// <summary>C3-1: public projection of <see cref="RuntimePositionConstrainPhase"/>.</summary>
|
|
public enum RuntimeInitialCreatePositionConstrainPhase : byte
|
|
{
|
|
None,
|
|
BeforePositionOperation,
|
|
AfterPositionOperation,
|
|
}
|
|
|
|
/// <summary>
|
|
/// C3-1: one Position continuation's route facts from the executor's trace,
|
|
/// projected to a public shape so a host can bind constrain/interpolation
|
|
/// presentation (retail's <c>ConstrainTo</c> placement, stop-interpolate,
|
|
/// zero-velocity, preserve-heading, send-position-immediately) without
|
|
/// reaching into internal Runtime route-classifier types.
|
|
/// Review addendum (2026-08-02): the route's <c>UnparentBeforeRouting</c>
|
|
/// ("unset_parent") and <c>ApplyPlacementFrameBeforeRouting</c>
|
|
/// ("SetPlacementFrame") facts are deliberately NOT projected here - the
|
|
/// executor's own merge (<c>ApplyAcceptedPositionSnapshot</c>'s
|
|
/// <c>clearParent</c>/<c>installPlacementFrame</c> parameters) already
|
|
/// applies both directly to the canonical snapshot, synchronously, before
|
|
/// the trace entry carrying this fact is even built. A host reading this
|
|
/// record must NOT re-apply either one - the facts this type DOES carry
|
|
/// (<see cref="ConstrainPhase"/>, <see cref="StopInterpolating"/>,
|
|
/// <see cref="ZeroVelocity"/>, <see cref="PreserveHeading"/>,
|
|
/// <see cref="SendPositionImmediately"/>, <see cref="HookPhase"/>) are
|
|
/// exactly the bindings still DEFERRED to the host at presentation time;
|
|
/// everything already-applied is intentionally excluded.
|
|
/// </summary>
|
|
public readonly record struct RuntimeInitialCreatePositionRouteFact(
|
|
ulong Sequence,
|
|
RuntimeInitialCreatePositionDisposition Disposition,
|
|
RuntimeInitialCreateTeleportHookPhase HookPhase,
|
|
RuntimeInitialCreatePositionConstrainPhase ConstrainPhase,
|
|
bool StopInterpolating,
|
|
bool ZeroVelocity,
|
|
bool PreserveHeading,
|
|
bool SendPositionImmediately);
|
|
|
|
/// <summary>
|
|
/// C3-1: the public host consumption shape for one executor drain's
|
|
/// completion, reached via
|
|
/// <see cref="RuntimePlacementProjectionChannel.TryGetInitialCreateCompletion"/>
|
|
/// using the correlated <see cref="RuntimePlacementProjectionKind.ExecutorCompleted"/>
|
|
/// receipt's own Entity/Sequence identity. Built exactly once, at completion
|
|
/// time, and cached alongside the internal receipt it projects (see
|
|
/// <see cref="RuntimeInitialCreateContinuationExecutor.ProjectCompletion"/>) -
|
|
/// a host retrying <c>TryGetInitialCreateCompletion</c> across multiple polls
|
|
/// never triggers a second allocation.
|
|
/// Review addendum (2026-08-02): <see cref="PositionRouteFacts"/>'s ARRAY
|
|
/// ORDER is the authoritative ordering, not <see cref="RuntimeInitialCreatePositionRouteFact.Sequence"/>
|
|
/// alone - Position facts drained from the SAME same-incarnation envelope
|
|
/// (<see cref="RuntimeInitialCreateExecutedAction.Stage"/> distinguishes
|
|
/// them internally, a field this projection does not carry) share one
|
|
/// continuation <c>Sequence</c>, so two entries can legitimately have equal
|
|
/// <c>Sequence</c> values. <see cref="RuntimeInitialCreateContinuationExecutor.ProjectCompletion"/>
|
|
/// walks the executor's trace strictly in construction order (itself the
|
|
/// exact FIFO drain order) and appends without reordering or deduplicating,
|
|
/// so array index - never a sort or group-by on <c>Sequence</c> - is the
|
|
/// only reliable way to recover drain order from this array.
|
|
/// </summary>
|
|
public readonly record struct RuntimeInitialCreatePlacementCompletion(
|
|
RuntimeEntityKey Entity,
|
|
uint FullCellId,
|
|
RuntimeInitialCreateTeleportHookPhase TeleportHookPhase,
|
|
ImmutableArray<RuntimeInitialCreatePositionRouteFact> PositionRouteFacts,
|
|
int ReplayedDeferredChildCount);
|
|
|
|
/// <summary>
|
|
/// Applies one entity's completed initial-Create residence: adopts the
|
|
/// initial placement exactly once, emits the AfterEnterWorld teleport-hook
|
|
/// request, replays raw missing-parent child Creates in FIFO order, drains
|
|
/// every retained continuation strictly by sequence (classifying Position
|
|
/// continuations at execution time against LIVE inputs), and releases the
|
|
/// residence once the drained prefix matches the lease's current length.
|
|
/// <c>Execute</c> is synchronous and retry-idempotent: a caller re-invokes it
|
|
/// after <see cref="RuntimeInitialCreateExecutionStatus.PendingPlacement"/> or
|
|
/// <see cref="RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement"/>
|
|
/// once the placement token in the returned trace has been prepared,
|
|
/// submitted, and acknowledged by whatever drives
|
|
/// <see cref="RuntimeSetPositionState"/> (a test harness today; a host at
|
|
/// cutover). This type never references App/UI/Silk.NET/OpenGL/OpenAL/
|
|
/// Headless and is reached only by tests in this slice - no production
|
|
/// caller exists yet.
|
|
/// </summary>
|
|
internal sealed class RuntimeInitialCreateContinuationExecutor
|
|
{
|
|
private enum InitialTailPhase : byte
|
|
{
|
|
NotStarted,
|
|
Adopted,
|
|
HookRecorded,
|
|
DeferredReplayed,
|
|
/// <summary>Round 5 R5-1: the accepted-relation queue for this guid has been drained.</summary>
|
|
RelationsReplayed,
|
|
}
|
|
|
|
private readonly record struct PendingPublish(
|
|
RuntimeEntityChange Change,
|
|
Func<bool> Matches,
|
|
RuntimePlacementCancellationReceipt Cancellation);
|
|
|
|
private sealed class Progress
|
|
{
|
|
internal required ulong LeaseId { get; init; }
|
|
internal ulong AppliedThroughSequence { get; set; }
|
|
internal InitialTailPhase TailPhase { get; set; }
|
|
internal int EnvelopeStageIndex { get; set; } = -1;
|
|
internal RuntimeEntityPlacementToken PendingContinuationPlacement { get; set; }
|
|
internal ulong PendingContinuationSequence { get; set; }
|
|
internal RuntimeAuthoritativePositionRoute PendingContinuationRoute { get; set; }
|
|
/// <summary>
|
|
/// Round 3 B1: true once a Position action's merge+publish has
|
|
/// committed but <c>TryBeginExclusiveAuthoredPlacement</c> failed on
|
|
/// transient operation-slot contention (another operation currently
|
|
/// owns this entity's SetPosition slot) rather than genuine
|
|
/// staleness. While true, a re-entry into <c>ApplyPositionAction</c>
|
|
/// for the SAME continuation/stage skips the merge/publish entirely
|
|
/// and retries only the placement begin - closing the "duplicate
|
|
/// publish on every retry" hole a naive full re-apply would open.
|
|
/// </summary>
|
|
internal bool PositionMergeCommittedForRetry { get; set; }
|
|
internal ulong PositionMergeCommittedVersion { get; set; }
|
|
internal int ReplayedDeferredChildCount { get; set; }
|
|
internal List<PendingPublish> EnvelopeBuffer { get; } = [];
|
|
internal ImmutableArray<RuntimeInitialCreateExecutedAction>.Builder Trace { get; } =
|
|
ImmutableArray.CreateBuilder<RuntimeInitialCreateExecutedAction>();
|
|
}
|
|
|
|
private readonly RuntimeEntityDirectory _entities;
|
|
private readonly RuntimeInitialCreateResidenceState _residences;
|
|
private readonly RuntimePhysicsState _physics;
|
|
private readonly RuntimeEntityObjectEventStream _events;
|
|
private readonly Func<WorldSession.EntitySpawn, bool, RuntimeEntityRegistrationResult>
|
|
_registerDeferredChild;
|
|
/// <summary>
|
|
/// Round 3 B12: mirrors
|
|
/// <see cref="RuntimeEntityObjectLifetime.ApplyAcceptedSpawn"/> for the
|
|
/// residence path's WeenieDescription tail action. The executor holds no
|
|
/// direct reference to <c>RuntimeEntityObjectLifetime</c> (it is
|
|
/// constructed BY that owner) or its <c>ClientObjectTable</c>, so the
|
|
/// lifetime binds this delegate at construction the same way it binds
|
|
/// <see cref="_registerDeferredChild"/>.
|
|
/// </summary>
|
|
private readonly Func<RuntimeEntityRecord, ulong, WorldSession.EntitySpawn, bool, bool>
|
|
_applyAcceptedSpawn;
|
|
private readonly Dictionary<RuntimeEntityKey, Progress> _progress = [];
|
|
private readonly HashSet<RuntimeEntityKey> _executing = [];
|
|
/// <summary>
|
|
/// C0-1: correlates a published
|
|
/// <see cref="RuntimePlacementProjectionKind.ExecutorCompleted"/>
|
|
/// receipt back to the full execution receipt/trace, keyed by the SAME
|
|
/// public Entity/Sequence identity every other Kind uses (the receipt's
|
|
/// own <c>Token.Entity</c>/<c>Token.Sequence</c>). Overwritten (never
|
|
/// accumulated) per entity key - an entity cannot have two drains
|
|
/// completing concurrently (<see cref="Execute"/>'s own <see cref="_executing"/>
|
|
/// reentrancy guard), so only the most recent completion for a key is
|
|
/// ever meaningful; the exact-sequence check in
|
|
/// <see cref="TryGetCompletionReceipt"/> rejects a stale lookup against a
|
|
/// superseded completion under a reused key. C3-1: the tuple's third
|
|
/// slot is the SAME receipt already projected once to the public
|
|
/// <see cref="RuntimeInitialCreatePlacementCompletion"/> shape (see
|
|
/// <see cref="ProjectCompletion"/>) - stored here, not recomputed per
|
|
/// read, so a host polling <c>TryGetInitialCreateCompletion</c> across
|
|
/// retries never allocates a second time for the same completion.
|
|
/// </summary>
|
|
private readonly Dictionary<RuntimeEntityKey,
|
|
(ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt,
|
|
RuntimeInitialCreatePlacementCompletion Public)>
|
|
_completionReceipts = [];
|
|
private Func<RuntimeGenerationToken>? _generation;
|
|
private Func<bool>? _usePositionFromServer;
|
|
private Func<Vector3?>? _localPlayerPosition;
|
|
private bool _liveInputsBound;
|
|
|
|
internal RuntimeInitialCreateContinuationExecutor(
|
|
RuntimeEntityDirectory entities,
|
|
RuntimeInitialCreateResidenceState residences,
|
|
RuntimePhysicsState physics,
|
|
RuntimeEntityObjectEventStream events,
|
|
Func<WorldSession.EntitySpawn, bool, RuntimeEntityRegistrationResult>
|
|
registerDeferredChild,
|
|
Func<RuntimeEntityRecord, ulong, WorldSession.EntitySpawn, bool, bool>
|
|
applyAcceptedSpawn)
|
|
{
|
|
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
|
_residences = residences
|
|
?? throw new ArgumentNullException(nameof(residences));
|
|
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
|
_events = events ?? throw new ArgumentNullException(nameof(events));
|
|
_registerDeferredChild = registerDeferredChild
|
|
?? throw new ArgumentNullException(nameof(registerDeferredChild));
|
|
_applyAcceptedSpawn = applyAcceptedSpawn
|
|
?? throw new ArgumentNullException(nameof(applyAcceptedSpawn));
|
|
}
|
|
|
|
internal void BindGeneration(Func<RuntimeGenerationToken> generation)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(generation);
|
|
if (_generation is not null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The initial-create continuation executor's generation source is already bound.");
|
|
}
|
|
_generation = generation;
|
|
}
|
|
|
|
/// <summary>
|
|
/// C0-2: binds Runtime's own live-input sources so no host ever computes
|
|
/// <see cref="RuntimeInitialCreateExecutionInputs.UsePositionFromServer"/>/
|
|
/// <see cref="RuntimeInitialCreateExecutionInputs.PlayerDistance"/>
|
|
/// itself. Optional/nullable exactly like <see cref="_generation"/> is
|
|
/// NOT (that one throws when unbound) - here an unbound source is a
|
|
/// legitimate, permanent state for bare-lifetime tests, which keep
|
|
/// constructing the executor without a <see cref="GameRuntime"/> and
|
|
/// keep driving <see cref="Execute"/> with an explicit caller-supplied
|
|
/// <see cref="RuntimeInitialCreateExecutionInputs"/> override (see
|
|
/// <see cref="ResolveInputs"/>). <see cref="GameRuntime"/> binds the real
|
|
/// owners - <c>RuntimeCharacterState.UsePositionFromServer</c> and the
|
|
/// live <c>RuntimeLocalPlayerMovementState.Controller</c> position -
|
|
/// once both exist (they are constructed AFTER
|
|
/// <see cref="RuntimeEntityObjectLifetime"/>/this executor, so this bind
|
|
/// cannot happen at the executor's own constructor time the way
|
|
/// <see cref="BindGeneration"/> does; it happens alongside
|
|
/// <c>BindEventContext</c> in <c>GameRuntime</c>'s construction
|
|
/// sequence). Throws if called twice, matching every other Bind* seam on
|
|
/// this class/its siblings (<see cref="BindGeneration"/>,
|
|
/// <c>RuntimeEntityObjectEventStream.BindContext"/>,
|
|
/// <c>RuntimePlacementProjectionChannel.BindGeneration</c>).
|
|
/// F3: <paramref name="localPlayerPosition"/> itself returns
|
|
/// <c>Vector3?</c>, not <c>Vector3</c> - a BOUND source with no live
|
|
/// controller yet (the login-window drain, before
|
|
/// <c>RuntimeLocalPlayerMovementState.Controller</c> exists) must yield
|
|
/// null, not a fabricated <c>Vector3.Zero</c>. <see cref="ResolveInputs"/>
|
|
/// falls back to the caller-supplied struct's PlayerDistance whenever
|
|
/// this source is unbound OR returns null - the SAME fallback rule
|
|
/// either way, never a synthetic origin-point distance that could
|
|
/// misclassify a remote entity as implausibly far (>96 m) during that
|
|
/// window.
|
|
/// </summary>
|
|
internal void BindLiveInputs(
|
|
Func<bool> usePositionFromServer,
|
|
Func<Vector3?> localPlayerPosition)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(usePositionFromServer);
|
|
ArgumentNullException.ThrowIfNull(localPlayerPosition);
|
|
if (_liveInputsBound)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The initial-create continuation executor's live-input sources are already bound.");
|
|
}
|
|
_usePositionFromServer = usePositionFromServer;
|
|
_localPlayerPosition = localPlayerPosition;
|
|
_liveInputsBound = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// C0-2: resolves the EFFECTIVE inputs for one <see cref="Execute"/>
|
|
/// call. A bound source always wins; the caller-supplied
|
|
/// <paramref name="inputs"/> struct is the test-override shape (its own
|
|
/// doc comment still describes production usage now that this method
|
|
/// exists) and is used verbatim only for whichever field has no bound
|
|
/// source - a bare-lifetime test that never calls
|
|
/// <see cref="BindLiveInputs"/> gets EXACTLY the caller-supplied values,
|
|
/// preserving every existing test's behavior unchanged.
|
|
/// <see cref="RuntimeInitialCreateExecutionInputs.PlayerDistance"/> uses
|
|
/// the SAME world-space basis as today's legacy remote path
|
|
/// (<c>LiveEntityNetworkUpdateController.cs</c>'s
|
|
/// <c>MaxPhysicsDistance</c>/<c>dist</c> computation, cutover-routes.md
|
|
/// route 4: <c>Vector3.Distance(worldPos, localPlayerPos)</c> where
|
|
/// <c>localPlayerPos</c> is the live physics-CONTROLLER position, never a
|
|
/// record snapshot) - here, <c>Vector3.Distance</c> between THIS
|
|
/// entity's own currently-accepted position (the exact field
|
|
/// <c>BeginAcceptedPlacementCore</c>/<c>CanonicalSetupTableId</c> already
|
|
/// trust: <c>Snapshot.Physics?.Position ?? Snapshot.Position</c>) and the
|
|
/// bound local-player controller position. Computed ONCE per
|
|
/// <see cref="Execute"/> call, matching the one-shot-per-call granularity
|
|
/// <paramref name="inputs"/> already had before this slice (retail
|
|
/// recomputes <c>player_distance</c> per wire packet; refining this
|
|
/// executor to per-continuation freshness is out of C0-2's scope).
|
|
/// </summary>
|
|
private RuntimeInitialCreateExecutionInputs ResolveInputs(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateExecutionInputs inputs)
|
|
{
|
|
bool usePositionFromServer = _usePositionFromServer is { } source
|
|
? source()
|
|
: inputs.UsePositionFromServer;
|
|
float playerDistance = inputs.PlayerDistance;
|
|
// F3: an unbound source AND a bound-but-null live position (no
|
|
// controller yet) both fall back to the caller-supplied struct
|
|
// identically - never fabricate Vector3.Zero as a stand-in.
|
|
if (_localPlayerPosition?.Invoke() is { } localPlayerPosition
|
|
&& (canonical.Snapshot.Physics?.Position
|
|
?? canonical.Snapshot.Position) is { } accepted)
|
|
{
|
|
var target = new Vector3(
|
|
accepted.PositionX, accepted.PositionY, accepted.PositionZ);
|
|
playerDistance = Vector3.Distance(target, localPlayerPosition);
|
|
}
|
|
return new RuntimeInitialCreateExecutionInputs(
|
|
usePositionFromServer, playerDistance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// C0-1: reaches the full execution receipt/trace correlated with an
|
|
/// observed <see cref="RuntimePlacementProjectionKind.ExecutorCompleted"/>
|
|
/// receipt, purely via that receipt's own public
|
|
/// <c>Token.Entity</c>/<c>Token.Sequence</c> identity - the same identity
|
|
/// every other placement Kind is acknowledged by. Returns false for a
|
|
/// superseded/stale sequence under a reused entity key.
|
|
/// </summary>
|
|
internal bool TryGetCompletionReceipt(
|
|
in RuntimePlacementProjectionToken token,
|
|
out RuntimeInitialCreateExecutionReceipt receipt)
|
|
{
|
|
if (_completionReceipts.TryGetValue(
|
|
token.Entity,
|
|
out (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt,
|
|
RuntimeInitialCreatePlacementCompletion Public) entry)
|
|
&& entry.Sequence == token.Sequence)
|
|
{
|
|
receipt = entry.Receipt;
|
|
return true;
|
|
}
|
|
receipt = default;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// C3-1: the public host consumption surface for
|
|
/// <see cref="TryGetCompletionReceipt"/> - same exact-sequence
|
|
/// correlation rule, but returns the cached public projection instead of
|
|
/// the internal receipt/trace. Reached via
|
|
/// <see cref="RuntimePlacementProjectionChannel.TryGetInitialCreateCompletion"/>.
|
|
/// </summary>
|
|
internal bool TryGetCompletion(
|
|
in RuntimePlacementProjectionToken token,
|
|
out RuntimeInitialCreatePlacementCompletion completion)
|
|
{
|
|
if (_completionReceipts.TryGetValue(
|
|
token.Entity,
|
|
out (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt,
|
|
RuntimeInitialCreatePlacementCompletion Public) entry)
|
|
&& entry.Sequence == token.Sequence)
|
|
{
|
|
completion = entry.Public;
|
|
return true;
|
|
}
|
|
completion = default;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review fix (2026-08-02, architecture pass): every arm is now listed
|
|
/// explicitly and the catch-all throws instead of silently folding an
|
|
/// unmapped future internal value into <c>None</c>. A value this method
|
|
/// cannot map must never reach a host disguised as "nothing to bind" -
|
|
/// that would silently drop presentation behavior (e.g. a real
|
|
/// teleport-hook phase host code never runs). See
|
|
/// <see cref="RuntimeInitialCreateContinuationExecutorTests"/>'s
|
|
/// reflection-based completeness test, which walks every declared
|
|
/// <see cref="RuntimeTeleportHookPhase"/> value through this exact
|
|
/// method and fails if a new internal value is ever added without a
|
|
/// matching arm here.
|
|
/// </summary>
|
|
private static RuntimeInitialCreateTeleportHookPhase MapHookPhase(
|
|
RuntimeTeleportHookPhase phase) => phase switch
|
|
{
|
|
RuntimeTeleportHookPhase.None =>
|
|
RuntimeInitialCreateTeleportHookPhase.None,
|
|
RuntimeTeleportHookPhase.BeforePositionOperation =>
|
|
RuntimeInitialCreateTeleportHookPhase.BeforePositionOperation,
|
|
RuntimeTeleportHookPhase.AfterPositionOperation =>
|
|
RuntimeInitialCreateTeleportHookPhase.AfterPositionOperation,
|
|
RuntimeTeleportHookPhase.AfterEnterWorld =>
|
|
RuntimeInitialCreateTeleportHookPhase.AfterEnterWorld,
|
|
_ => throw new ArgumentOutOfRangeException(
|
|
nameof(phase),
|
|
phase,
|
|
$"Unmapped {nameof(RuntimeTeleportHookPhase)} value - add an explicit arm to {nameof(MapHookPhase)} and to the public {nameof(RuntimeInitialCreateTeleportHookPhase)} projection."),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Review fix (2026-08-02): see <see cref="MapHookPhase"/>'s remarks -
|
|
/// same explicit-arms-plus-throwing-catch-all discipline.
|
|
/// </summary>
|
|
private static RuntimeInitialCreatePositionDisposition MapDisposition(
|
|
RuntimeAuthoritativePositionDisposition disposition) => disposition switch
|
|
{
|
|
RuntimeAuthoritativePositionDisposition.RejectedAuthority =>
|
|
RuntimeInitialCreatePositionDisposition.RejectedAuthority,
|
|
RuntimeAuthoritativePositionDisposition.RejectedData =>
|
|
RuntimeInitialCreatePositionDisposition.RejectedData,
|
|
RuntimeAuthoritativePositionDisposition.AwaitFreshPosition =>
|
|
RuntimeInitialCreatePositionDisposition.AwaitFreshPosition,
|
|
RuntimeAuthoritativePositionDisposition.NoPositionOperation =>
|
|
RuntimeInitialCreatePositionDisposition.NoPositionOperation,
|
|
RuntimeAuthoritativePositionDisposition.Interpolate =>
|
|
RuntimeInitialCreatePositionDisposition.Interpolate,
|
|
RuntimeAuthoritativePositionDisposition.SetPosition =>
|
|
RuntimeInitialCreatePositionDisposition.SetPosition,
|
|
RuntimeAuthoritativePositionDisposition.SetPositionSimple =>
|
|
RuntimeInitialCreatePositionDisposition.SetPositionSimple,
|
|
_ => throw new ArgumentOutOfRangeException(
|
|
nameof(disposition),
|
|
disposition,
|
|
$"Unmapped {nameof(RuntimeAuthoritativePositionDisposition)} value - add an explicit arm to {nameof(MapDisposition)} and to the public {nameof(RuntimeInitialCreatePositionDisposition)} projection."),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Review fix (2026-08-02): see <see cref="MapHookPhase"/>'s remarks -
|
|
/// same explicit-arms-plus-throwing-catch-all discipline.
|
|
/// </summary>
|
|
private static RuntimeInitialCreatePositionConstrainPhase MapConstrainPhase(
|
|
RuntimePositionConstrainPhase phase) => phase switch
|
|
{
|
|
RuntimePositionConstrainPhase.None =>
|
|
RuntimeInitialCreatePositionConstrainPhase.None,
|
|
RuntimePositionConstrainPhase.BeforePositionOperation =>
|
|
RuntimeInitialCreatePositionConstrainPhase.BeforePositionOperation,
|
|
RuntimePositionConstrainPhase.AfterPositionOperation =>
|
|
RuntimeInitialCreatePositionConstrainPhase.AfterPositionOperation,
|
|
_ => throw new ArgumentOutOfRangeException(
|
|
nameof(phase),
|
|
phase,
|
|
$"Unmapped {nameof(RuntimePositionConstrainPhase)} value - add an explicit arm to {nameof(MapConstrainPhase)} and to the public {nameof(RuntimeInitialCreatePositionConstrainPhase)} projection."),
|
|
};
|
|
|
|
/// <summary>
|
|
/// C3-1: projects an internal <see cref="RuntimeInitialCreateExecutionReceipt"/>
|
|
/// to the public <see cref="RuntimeInitialCreatePlacementCompletion"/>
|
|
/// shape exactly once, at completion time (see the
|
|
/// <c>_completionReceipts</c> assignment in <see cref="ExecuteCore"/>).
|
|
/// Only Position-kind trace entries carry route facts meaningful for
|
|
/// constrain/interpolation binding; every other action kind
|
|
/// (InitialAdoption, TeleportHookRequest, replay, envelope stages, ...)
|
|
/// is intentionally excluded from <see cref="RuntimeInitialCreatePlacementCompletion.PositionRouteFacts"/> -
|
|
/// widening this to every trace entry would require making the whole
|
|
/// internal action-kind vocabulary public, which the pinned contract
|
|
/// explicitly prefers to avoid.
|
|
/// </summary>
|
|
private static RuntimeInitialCreatePlacementCompletion ProjectCompletion(
|
|
in RuntimeInitialCreateExecutionReceipt receipt)
|
|
{
|
|
ImmutableArray<RuntimeInitialCreateExecutedAction> trace = receipt.Trace;
|
|
int positionCount = 0;
|
|
for (int i = 0; i < trace.Length; i++)
|
|
{
|
|
if (trace[i].Kind == RuntimeInitialCreateExecutedActionKind.Position)
|
|
positionCount++;
|
|
}
|
|
|
|
ImmutableArray<RuntimeInitialCreatePositionRouteFact> positionFacts;
|
|
if (positionCount == 0)
|
|
{
|
|
positionFacts = ImmutableArray<RuntimeInitialCreatePositionRouteFact>.Empty;
|
|
}
|
|
else
|
|
{
|
|
var builder = ImmutableArray.CreateBuilder<RuntimeInitialCreatePositionRouteFact>(
|
|
positionCount);
|
|
for (int i = 0; i < trace.Length; i++)
|
|
{
|
|
RuntimeInitialCreateExecutedAction action = trace[i];
|
|
if (action.Kind != RuntimeInitialCreateExecutedActionKind.Position)
|
|
continue;
|
|
// Review fix (2026-08-02): PositionDisposition is nullable
|
|
// on RuntimeInitialCreateExecutedAction because it is only
|
|
// meaningful for Position/hook-request entries in general -
|
|
// but BuildPositionTrace is the SOLE constructor of
|
|
// Kind.Position entries (grep-confirmed, 5 call sites, all
|
|
// through BuildPositionTrace) and it always passes
|
|
// route.Disposition, a non-nullable enum, into this slot.
|
|
// Null is therefore NOT a legitimate state for a Position-
|
|
// kind entry specifically - a silent `?? NoPositionOperation`
|
|
// fallback here would have hidden a real bug (a future
|
|
// Position-trace producer that forgot to set it) behind a
|
|
// plausible-looking default. Fail loudly instead.
|
|
if (action.PositionDisposition is not { } disposition)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"A Position-kind executor trace entry must always " +
|
|
"carry a non-null PositionDisposition - " +
|
|
"BuildPositionTrace (the sole producer of Kind.Position " +
|
|
"entries) always supplies route.Disposition.");
|
|
}
|
|
builder.Add(new RuntimeInitialCreatePositionRouteFact(
|
|
action.Sequence,
|
|
MapDisposition(disposition),
|
|
MapHookPhase(action.HookPhase),
|
|
MapConstrainPhase(action.ConstrainPhase),
|
|
action.StopInterpolating,
|
|
action.ZeroVelocity,
|
|
action.PreserveHeading,
|
|
action.SendPositionImmediately));
|
|
}
|
|
positionFacts = builder.MoveToImmutable();
|
|
}
|
|
|
|
return new RuntimeInitialCreatePlacementCompletion(
|
|
receipt.Entity,
|
|
receipt.FullCellId,
|
|
MapHookPhase(receipt.TeleportHookPhase),
|
|
positionFacts,
|
|
receipt.ReplayedDeferredChildCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F2: reaps exactly one completion-receipt correlation entry, bound as
|
|
/// <see cref="RuntimeSetPositionState.BindExecutorCompletionAcknowledgement"/>'s
|
|
/// notification callback - fired the moment a host acknowledges the
|
|
/// Kind ExecutorCompleted receipt this entry correlates, never before.
|
|
/// The exact-sequence check rejects removing a NEWER completion's entry
|
|
/// under a reused key (mirrors <see cref="TryGetCompletionReceipt"/>'s
|
|
/// own currency check).
|
|
/// </summary>
|
|
internal void ForgetCompletionReceipt(RuntimeEntityKey key, ulong sequence)
|
|
{
|
|
if (_completionReceipts.TryGetValue(
|
|
key,
|
|
out (ulong Sequence, RuntimeInitialCreateExecutionReceipt Receipt,
|
|
RuntimeInitialCreatePlacementCompletion Public) entry)
|
|
&& entry.Sequence == sequence)
|
|
{
|
|
_completionReceipts.Remove(key);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// F2: folded into <see cref="RuntimeEntityObjectOwnershipSnapshot"/>/
|
|
/// <c>IsConverged</c> - an unacknowledged completion receipt is
|
|
/// outstanding host debt, mirroring
|
|
/// <see cref="RuntimeSetPositionOwnershipSnapshot.PendingProjectionAcknowledgementCount"/>'s
|
|
/// existing "must be zero to converge" shape for the SAME underlying
|
|
/// receipt stream.
|
|
/// </summary>
|
|
internal int PendingCompletionReceiptCount => _completionReceipts.Count;
|
|
|
|
internal int ProgressCount => _progress.Count;
|
|
|
|
/// <summary>
|
|
/// Round 5 R5-3: mirrors the <see cref="RuntimeEntityObjectEventStream"/>
|
|
/// DispatchFailureCount/LastDispatchFailure precedent for the deferred-
|
|
/// replay containment introduced by Round 4 R4-1 and extended by this
|
|
/// round's deferred-relation replay. A contained catch never silently
|
|
/// swallows - it increments this counter and records the exception,
|
|
/// then keeps draining the remaining entries.
|
|
/// </summary>
|
|
internal long ReplayFailureCount { get; private set; }
|
|
internal Exception? LastReplayFailure { get; private set; }
|
|
|
|
private void RecordReplayFailure(Exception error)
|
|
{
|
|
ReplayFailureCount++;
|
|
LastReplayFailure = error;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exposes the exact placement token a
|
|
/// <see cref="RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement"/>
|
|
/// yield is waiting on, so a caller (a test harness today; a host at
|
|
/// cutover) can drive <see cref="RuntimeSetPositionState"/>'s ordinary
|
|
/// prepare/submit/acknowledge cycle on it, exactly like it already does
|
|
/// for the initial lease's own placement token.
|
|
/// </summary>
|
|
internal bool TryGetPendingContinuationPlacement(
|
|
RuntimeEntityKey key,
|
|
out RuntimeEntityPlacementToken placement)
|
|
{
|
|
if (_progress.TryGetValue(key, out Progress? progress)
|
|
&& progress.PendingContinuationPlacement.IsValid)
|
|
{
|
|
placement = progress.PendingContinuationPlacement;
|
|
return true;
|
|
}
|
|
placement = default;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exposes the exact classified route the pending continuation placement
|
|
/// is running, so a caller can drive
|
|
/// <see cref="RuntimeSetPositionState.PrepareMover"/> with the matching
|
|
/// <see cref="RuntimeSetPositionOperationKind"/>/<see cref="PhysicsSetPositionFlags"/>
|
|
/// - the same information <see cref="RuntimeInitialCreateResidenceLease.Route"/>
|
|
/// already exposes for the initial placement.
|
|
/// </summary>
|
|
internal bool TryGetPendingContinuationRoute(
|
|
RuntimeEntityKey key,
|
|
out RuntimeAuthoritativePositionRoute route)
|
|
{
|
|
if (_progress.TryGetValue(key, out Progress? progress)
|
|
&& progress.PendingContinuationPlacement.IsValid)
|
|
{
|
|
route = progress.PendingContinuationRoute;
|
|
return true;
|
|
}
|
|
route = default;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deterministic cleanup hook wired into the SAME choke points that
|
|
/// forget a residence lease (<see cref="RuntimeEntityObjectLifetime.ForgetInitialCreateResidence"/>).
|
|
/// A retired residence can never leave orphaned executor progress
|
|
/// behind. Also forgets any in-flight CONTINUATION placement token
|
|
/// (distinct from the residence's own initial-lease placement, which
|
|
/// <c>ForgetInitialCreateResidence</c> already forgets separately, and
|
|
/// distinct from the unconditional <c>Physics.SetPosition.Forget</c>
|
|
/// every existing <c>ForgetInitialCreateResidence</c> caller already
|
|
/// runs alongside it - which independently cancels whatever operation
|
|
/// currently exists for this key, continuation placement included).
|
|
/// This is defensive-in-depth: DiscardProgress owns cleanup of the
|
|
/// state IT introduces (PendingContinuationPlacement) rather than
|
|
/// relying on every current AND future caller pairing it with an
|
|
/// ordinary Forget of its own.
|
|
/// </summary>
|
|
internal void DiscardProgress(RuntimeEntityKey key)
|
|
{
|
|
// F2: reap this key's completion-receipt correlation entry
|
|
// unconditionally - DiscardProgress owns cleanup of every piece of
|
|
// state IT introduces, and this cache is exactly that (see
|
|
// _completionReceipts's own doc comment). Independent of whether
|
|
// _progress still tracks this key: a completed drain has ALREADY
|
|
// removed its own Progress entry before this correlation entry was
|
|
// ever added (see ExecuteCore's Released case), so this is the
|
|
// ONLY choke point that reaps it outside of a normal acknowledge.
|
|
_completionReceipts.Remove(key);
|
|
if (!_progress.Remove(key, out Progress? progress))
|
|
return;
|
|
if (progress.PendingContinuationPlacement.IsValid)
|
|
{
|
|
RuntimePlacementCancellationReceipt cancellation =
|
|
_physics.SetPosition.ForgetExactPlacement(
|
|
progress.PendingContinuationPlacement);
|
|
_physics.SetPosition.PublishCancellation(cancellation);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deterministic bulk cleanup wired into
|
|
/// <see cref="RuntimeInitialCreateResidenceState.Clear"/>'s call site
|
|
/// (<see cref="RuntimeEntityObjectLifetime.BeginSessionClear"/>). Also
|
|
/// forgets every in-flight continuation placement token, defensively -
|
|
/// <c>Physics.ResetSessionPhysics()</c> runs immediately after this in
|
|
/// the same session-clear sequence and would otherwise be the only
|
|
/// thing to reap them.
|
|
/// </summary>
|
|
internal void DiscardAll()
|
|
{
|
|
foreach (Progress progress in _progress.Values)
|
|
{
|
|
if (!progress.PendingContinuationPlacement.IsValid)
|
|
continue;
|
|
RuntimePlacementCancellationReceipt cancellation =
|
|
_physics.SetPosition.ForgetExactPlacement(
|
|
progress.PendingContinuationPlacement);
|
|
_physics.SetPosition.PublishCancellation(cancellation);
|
|
}
|
|
_progress.Clear();
|
|
// F2: bulk-reap every completion-receipt correlation entry - a full
|
|
// session clear must not carry any of this cache across a reset.
|
|
_completionReceipts.Clear();
|
|
}
|
|
|
|
internal RuntimeInitialCreateExecutionStatus Execute(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
in RuntimeInitialCreateExecutionInputs inputs,
|
|
out RuntimeInitialCreateExecutionReceipt receipt)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(canonical);
|
|
receipt = default;
|
|
if (!token.IsValid || canonical.Key is not { } key)
|
|
return RuntimeInitialCreateExecutionStatus.RejectedToken;
|
|
|
|
// A reentrant Execute for the SAME entity while one is already on the
|
|
// stack (e.g. a synchronous event observer re-entering) fails closed
|
|
// rather than interleaving two drains of the same FIFO.
|
|
if (!_executing.Add(key))
|
|
return RuntimeInitialCreateExecutionStatus.RejectedAuthority;
|
|
|
|
try
|
|
{
|
|
return ExecuteCore(canonical, token, inputs, key, out receipt);
|
|
}
|
|
finally
|
|
{
|
|
_executing.Remove(key);
|
|
}
|
|
}
|
|
|
|
private RuntimeInitialCreateExecutionStatus ExecuteCore(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
in RuntimeInitialCreateExecutionInputs inputs,
|
|
RuntimeEntityKey key,
|
|
out RuntimeInitialCreateExecutionReceipt receipt)
|
|
{
|
|
receipt = default;
|
|
// C0-2: resolve ONCE per Execute call - a bound Runtime source always
|
|
// wins over the caller-supplied test-override struct (see
|
|
// ResolveInputs's own doc comment for the exact fallback rule).
|
|
RuntimeInitialCreateExecutionInputs effectiveInputs =
|
|
ResolveInputs(canonical, inputs);
|
|
|
|
// An existing Progress for a DIFFERENT (older or ABA-reused) lease
|
|
// id is discarded here, and THIS exact call fails closed - an old
|
|
// incarnation's progress can never leak into a reused GUID/key. A
|
|
// retry with no prior progress starts fresh and succeeds normally.
|
|
if (_progress.TryGetValue(key, out Progress? existing)
|
|
&& existing.LeaseId != token.LeaseId)
|
|
{
|
|
DiscardProgress(key);
|
|
return RuntimeInitialCreateExecutionStatus.RejectedAuthority;
|
|
}
|
|
|
|
// Round 3 B11: a FRESH Progress for the CURRENT lease id is never
|
|
// materialized here - only lazily below, once Complete() actually
|
|
// reports Completed. A PendingPlacement/RejectedToken/RejectedAuthority
|
|
// outcome on THIS call must leave the ownership ledger (ProgressCount)
|
|
// untouched when nothing was ever tracked before - it should reflect
|
|
// drain work actually in flight, not a placeholder for a residence
|
|
// that has not even resolved yet.
|
|
Progress? progress = existing;
|
|
|
|
// Resume a placement that a PREVIOUS Execute call began and yielded
|
|
// on, before doing anything else. This can belong either to a
|
|
// standalone Position continuation or to a Position stage inside a
|
|
// SameIncarnationCreate envelope; ApplyContinuation/ApplyEnvelope
|
|
// both check PendingContinuationPlacement first for exactly this
|
|
// reason.
|
|
while (true)
|
|
{
|
|
// The ONLY legitimate window where one of the four baseline
|
|
// fields can move BETWEEN Execute calls without the executor's
|
|
// own synchronous code running is a pending continuation
|
|
// placement's host-driven prepare/submit/acknowledge cycle
|
|
// (RuntimeSetPositionState's own commit machinery advances
|
|
// FullCellId/PlacementCommitVersion there). Re-sync the
|
|
// baseline ONLY when that exact window was left open by a
|
|
// PREVIOUS call - never unconditionally, or every call with
|
|
// nothing in flight would bless an external race on these
|
|
// fields before Complete() ever gets a chance to see it. Safe
|
|
// even when the placement was displaced/cancelled instead of
|
|
// committed: ResumePendingPlacement below independently
|
|
// re-derives that outcome from
|
|
// IsPlacementCurrent/TryPeekAcknowledgedPlacement, not from
|
|
// these four fields.
|
|
if (progress is not null && progress.PendingContinuationPlacement.IsValid)
|
|
{
|
|
// Round 4 R4-4: only FullCellId/PlacementCommitVersion can
|
|
// legitimately move in this exact window (RuntimeSetPositionState's
|
|
// own commit machinery, not the executor) - PositionAuthorityVersion/
|
|
// CreateIntegrationVersion moving here would be a genuine
|
|
// external race Complete() must still catch.
|
|
_residences.AdvanceExecutorBaseline(
|
|
canonical,
|
|
token,
|
|
RuntimeExecutorBaselineFields.FullCellId
|
|
| RuntimeExecutorBaselineFields.PlacementCommitVersion);
|
|
}
|
|
RuntimeInitialCreateResidenceCompletionStatus completion =
|
|
_residences.Complete(canonical, token, out RuntimeInitialCreateResidenceReceipt residenceReceipt);
|
|
switch (completion)
|
|
{
|
|
case RuntimeInitialCreateResidenceCompletionStatus.PendingPlacement:
|
|
// Nothing has been drained yet for this exact lease -
|
|
// leave _progress exactly as found (untouched if it
|
|
// never existed).
|
|
return RuntimeInitialCreateExecutionStatus.PendingPlacement;
|
|
case RuntimeInitialCreateResidenceCompletionStatus.RejectedToken:
|
|
DiscardProgress(key);
|
|
return RuntimeInitialCreateExecutionStatus.RejectedToken;
|
|
case RuntimeInitialCreateResidenceCompletionStatus.RejectedAuthority:
|
|
DiscardProgress(key);
|
|
return RuntimeInitialCreateExecutionStatus.RejectedAuthority;
|
|
}
|
|
|
|
// Completed: a residence now exists to drain. Materialize
|
|
// Progress exactly once, lazily, only at this point.
|
|
if (progress is null)
|
|
{
|
|
progress = new Progress { LeaseId = token.LeaseId };
|
|
_progress[key] = progress;
|
|
}
|
|
|
|
if (progress.TailPhase != InitialTailPhase.DeferredReplayed)
|
|
{
|
|
RuntimeInitialCreateExecutionStatus tailStatus =
|
|
RunInitialTail(canonical, token, residenceReceipt, progress);
|
|
if (tailStatus != RuntimeInitialCreateExecutionStatus.Completed)
|
|
return Abandon(canonical, key);
|
|
}
|
|
|
|
while (progress.AppliedThroughSequence
|
|
< (ulong)residenceReceipt.Continuations.Length)
|
|
{
|
|
if (!_entities.IsCurrent(canonical) || canonical.Key != token.Entity)
|
|
return Abandon(canonical, key);
|
|
|
|
int index = (int)progress.AppliedThroughSequence;
|
|
RuntimeInitialCreateResidenceContinuation continuation =
|
|
residenceReceipt.Continuations[index];
|
|
if (continuation.InstanceSequence != canonical.Incarnation)
|
|
return Abandon(canonical, key);
|
|
|
|
RuntimeInitialCreateExecutionStatus applyStatus =
|
|
ApplyContinuation(canonical, token, key, continuation, effectiveInputs, progress);
|
|
// Round 3 B2: every apply method below now rebaselines
|
|
// itself immediately after its own canonical mutation and
|
|
// BEFORE its own publish (mutate -> rebaseline -> publish),
|
|
// closing the reentrant-retirement window a synchronous
|
|
// Publish observer could otherwise see (the baseline would
|
|
// still show the PRE-mutation values while the observer
|
|
// reenters residence/executor state). No blanket
|
|
// re-synchronize belongs here anymore - each apply already
|
|
// guarantees its own baseline is current before ANY
|
|
// observer can run.
|
|
if (applyStatus
|
|
== RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement)
|
|
{
|
|
return applyStatus;
|
|
}
|
|
if (applyStatus != RuntimeInitialCreateExecutionStatus.Completed)
|
|
return applyStatus;
|
|
|
|
progress.AppliedThroughSequence = continuation.Sequence;
|
|
progress.EnvelopeStageIndex = -1;
|
|
}
|
|
|
|
RuntimeInitialCreateResidenceExecutorReleaseStatus release =
|
|
_residences.ConsumeExecuted(
|
|
canonical,
|
|
residenceReceipt.Adoption,
|
|
progress.AppliedThroughSequence);
|
|
switch (release)
|
|
{
|
|
case RuntimeInitialCreateResidenceExecutorReleaseStatus.Released:
|
|
{
|
|
var completedReceipt = new RuntimeInitialCreateExecutionReceipt(
|
|
key,
|
|
residenceReceipt.FullCellId,
|
|
residenceReceipt.TeleportHookPhase,
|
|
progress.Trace.ToImmutable(),
|
|
progress.ReplayedDeferredChildCount);
|
|
receipt = completedReceipt;
|
|
// C3-1: project to the public host-consumption shape
|
|
// exactly once here, alongside the internal receipt -
|
|
// never recomputed per host read/retry (see
|
|
// ProjectCompletion's and _completionReceipts's own doc
|
|
// comments).
|
|
RuntimeInitialCreatePlacementCompletion publicCompletion =
|
|
ProjectCompletion(completedReceipt);
|
|
_progress.Remove(key);
|
|
// C0-1: bridge the executor's own completion onto the
|
|
// SAME ordered placement receipt stream every
|
|
// Place/Withdraw/Discard uses (canonical is still
|
|
// current here - nothing between the last continuation
|
|
// apply and ConsumeExecuted's Released outcome mutates
|
|
// it). Correlate the full trace via the fresh token's
|
|
// Entity/Sequence identity - see TryGetCompletionReceipt.
|
|
// F2: registration happens INSIDE PublishExecutorCompletion's
|
|
// beforePublish callback (before the synchronous observer
|
|
// dispatch), not after this call returns - a subscriber
|
|
// reading the correlation back from inside its own
|
|
// OnPlacement callback must already find it. receipt is
|
|
// copied to a local (completedReceipt) because an `out`
|
|
// parameter cannot be captured by a lambda.
|
|
_physics.SetPosition.PublishExecutorCompletion(
|
|
canonical,
|
|
beforePublish: token =>
|
|
_completionReceipts[key] =
|
|
(token.Sequence, completedReceipt, publicCompletion));
|
|
return RuntimeInitialCreateExecutionStatus.Completed;
|
|
}
|
|
case RuntimeInitialCreateResidenceExecutorReleaseStatus.Revised:
|
|
// A new continuation arrived mid-drain (Enqueue bumps the
|
|
// completed entry's Adoption.Revision in place). Re-fetch
|
|
// via Complete and drain only the newly-appended tail -
|
|
// AppliedThroughSequence already reflects everything this
|
|
// progress has committed, so the outer while(true) loop's
|
|
// inner drain loop naturally continues from there.
|
|
continue;
|
|
default:
|
|
return Abandon(canonical, key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 3 B1: the ONE choke point every abandonment path routes
|
|
/// through. Retiring the RESIDENCE itself (not just this executor's own
|
|
/// progress) is essential here - a caller that only discarded progress
|
|
/// and returned RejectedAuthority would leave the residence's own
|
|
/// completed entry sitting there fully current; the NEXT Execute call
|
|
/// for the same key would re-fetch it via Complete(), start a FRESH
|
|
/// Progress at sequence zero, and REPLAY every continuation already
|
|
/// committed to the canonical snapshot in this attempt.
|
|
/// <see cref="RuntimeInitialCreateResidenceState.Forget"/>'s own
|
|
/// retirement notification (bound at
|
|
/// <see cref="RuntimeEntityObjectLifetime"/> construction) already
|
|
/// routes back to <see cref="DiscardProgress"/> for a successful Forget;
|
|
/// the explicit call here is the same idempotent defense-in-depth every
|
|
/// other DiscardProgress caller uses, covering the case where Forget
|
|
/// finds no matching residence at all (nothing left to retire, but this
|
|
/// key's own progress must still go).
|
|
/// </summary>
|
|
private RuntimeInitialCreateExecutionStatus Abandon(
|
|
RuntimeEntityRecord canonical,
|
|
RuntimeEntityKey key)
|
|
{
|
|
if (_residences.Forget(
|
|
canonical,
|
|
out _,
|
|
out RuntimePlacementCancellationReceipt cancellation))
|
|
{
|
|
_physics.SetPosition.PublishCancellation(cancellation);
|
|
}
|
|
DiscardProgress(key);
|
|
return RuntimeInitialCreateExecutionStatus.RejectedAuthority;
|
|
}
|
|
|
|
private RuntimeInitialCreateExecutionStatus RunInitialTail(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
in RuntimeInitialCreateResidenceReceipt residenceReceipt,
|
|
Progress progress)
|
|
{
|
|
if (progress.TailPhase == InitialTailPhase.NotStarted)
|
|
{
|
|
// Resolves the runtime-surface.md 3.1 deadlock: BeginAcceptedPlacementCore
|
|
// (every placement-begin entry point) rejects while HasRetainedCompletion
|
|
// is true for this key. Consuming the initial placement's
|
|
// acknowledged completion here - exactly once, guarded by
|
|
// PlacementAdopted - is what lets a later Position continuation
|
|
// begin its OWN authored placement for the same key.
|
|
if (!_residences.AdoptCompletedPlacement(canonical, token))
|
|
return RuntimeInitialCreateExecutionStatus.RejectedAuthority;
|
|
progress.Trace.Add(new RuntimeInitialCreateExecutedAction(
|
|
RuntimeInitialCreateExecutedActionKind.InitialAdoption,
|
|
0UL,
|
|
-1,
|
|
null,
|
|
RuntimeTeleportHookPhase.None));
|
|
progress.TailPhase = InitialTailPhase.Adopted;
|
|
}
|
|
|
|
if (progress.TailPhase == InitialTailPhase.Adopted)
|
|
{
|
|
// Retail: SmartBox new-object player branch, init_player /
|
|
// PlayerPositionUpdated (function 1 in retail-notes.md,
|
|
// SmartBox::HandleCreateObject 0x00454c80). The hook REQUEST is
|
|
// the Runtime-side fact; a host runs the actual after-enter
|
|
// teleport suffix at cutover.
|
|
if (residenceReceipt.TeleportHookPhase
|
|
== RuntimeTeleportHookPhase.AfterEnterWorld)
|
|
{
|
|
progress.Trace.Add(new RuntimeInitialCreateExecutedAction(
|
|
RuntimeInitialCreateExecutedActionKind.TeleportHookRequest,
|
|
0UL,
|
|
-1,
|
|
null,
|
|
RuntimeTeleportHookPhase.AfterEnterWorld));
|
|
}
|
|
progress.TailPhase = InitialTailPhase.HookRecorded;
|
|
}
|
|
|
|
if (progress.TailPhase == InitialTailPhase.HookRecorded)
|
|
{
|
|
if (!ReplayDeferredChildren(canonical, progress))
|
|
return RuntimeInitialCreateExecutionStatus.RejectedAuthority;
|
|
progress.TailPhase = InitialTailPhase.DeferredReplayed;
|
|
}
|
|
|
|
if (progress.TailPhase == InitialTailPhase.DeferredReplayed)
|
|
{
|
|
// Round 5 R5-1: drains the accepted-relation queue keyed to
|
|
// THIS guid AFTER the raw-Create replay above - wire-arrival
|
|
// order means any relation waiting on this exact guid was
|
|
// queued no earlier than the raw children were (retail's
|
|
// ProcessObjectNetBlobs replays both classes of blob from the
|
|
// SAME per-guid bucket; our queues are split by shape but
|
|
// drained in the same relative order).
|
|
if (!ReplayDeferredAcceptedRelations(canonical, progress))
|
|
return RuntimeInitialCreateExecutionStatus.RejectedAuthority;
|
|
progress.TailPhase = InitialTailPhase.RelationsReplayed;
|
|
}
|
|
|
|
return RuntimeInitialCreateExecutionStatus.Completed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail: SmartBox::ProcessObjectNetBlobs 0x00454b20, called at the tail
|
|
/// of HandleCreateObject's new-object path for the object that was just
|
|
/// created - a parent's own successful Create replays every blob queued
|
|
/// waiting on ITS guid, synchronously, in the same call stack, in FIFO
|
|
/// order. Wire-arrival order means this replay runs BEFORE the FIFO
|
|
/// drain below: children were queued before any continuation targeting
|
|
/// this entity itself could exist.
|
|
///
|
|
/// Round 3 B7: retail detaches the ENTIRE queued netblob list for one
|
|
/// parent atomically before dispatching any of it (pseudo-C ~93617) -
|
|
/// "detach" IS retail's "consume"; there is no separate peek-then-remove
|
|
/// step. This replaces the previous peek/consume loop, which needed a
|
|
/// stale-AdmissionId escape hatch for a race that an atomic detach makes
|
|
/// structurally impossible: a NEW Create arriving for this same parent
|
|
/// during replay enqueues into a brand-new queue instance, since
|
|
/// DetachDeferredCreates already removed the old one from the
|
|
/// dictionary before this loop starts.
|
|
///
|
|
/// Round 4 R4-1: two containment gaps closed. (1) one child's
|
|
/// registration THROWING no longer escapes <c>Execute</c> or strands
|
|
/// the remaining siblings - each registration runs inside a try/catch,
|
|
/// recording a <see cref="RuntimeDeferredChildReplayOutcome.Rejected"/>
|
|
/// outcome and continuing with the next entry on an exception. (2) a
|
|
/// mid-loop abandonment (this entity no longer current - e.g. a
|
|
/// reentrant delete/reset fired synchronously from an earlier sibling's
|
|
/// own registration callback) restores the UNPROCESSED remainder into
|
|
/// <see cref="ParentAttachmentState"/> - in original FIFO order, with
|
|
/// original AdmissionIds - rather than permanently destroying it.
|
|
/// Retail's own queued blobs live on <c>CObjectMaint</c> (per-GUID), not
|
|
/// on the object instance being replayed, so they survive the object
|
|
/// and replay again against a recreated GUID; our GUID-keyed
|
|
/// persistence already pins this, RestoreDeferredCreates just makes an
|
|
/// abandoned-mid-replay attempt honor it too.
|
|
/// </summary>
|
|
private bool ReplayDeferredChildren(RuntimeEntityRecord canonical, Progress progress)
|
|
{
|
|
if (!_entities.IsCurrent(canonical))
|
|
return false;
|
|
|
|
ImmutableArray<DeferredParentCreate> detached =
|
|
_entities.ParentAttachments.DetachDeferredCreates(
|
|
canonical.ServerGuid, out DeferredReplayWindowToken window);
|
|
for (int index = 0; index < detached.Length; index++)
|
|
{
|
|
if (!_entities.IsCurrent(canonical))
|
|
{
|
|
_entities.ParentAttachments.RestoreDeferredCreates(
|
|
window,
|
|
detached.AsSpan()[index..]);
|
|
return false;
|
|
}
|
|
|
|
DeferredParentCreate deferred = detached[index];
|
|
RuntimeDeferredChildReplayOutcome outcome;
|
|
try
|
|
{
|
|
RuntimeEntityRegistrationResult result =
|
|
_registerDeferredChild(deferred.Spawn, deferred.IsLocalPlayer);
|
|
outcome = result.Canonical is not null
|
|
? RuntimeDeferredChildReplayOutcome.Registered
|
|
: result.DeferredForParent
|
|
? RuntimeDeferredChildReplayOutcome.ReDeferred
|
|
: RuntimeDeferredChildReplayOutcome.Rejected;
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
// Round 4 R4-1: contain the exception here - one bad child
|
|
// must not strand the remaining siblings or escape Execute
|
|
// as a typed status. Round 5 R5-3: record it on the
|
|
// observable failure surface rather than swallowing it.
|
|
RecordReplayFailure(error);
|
|
outcome = RuntimeDeferredChildReplayOutcome.Rejected;
|
|
}
|
|
progress.Trace.Add(new RuntimeInitialCreateExecutedAction(
|
|
RuntimeInitialCreateExecutedActionKind.DeferredChildReplay,
|
|
0UL,
|
|
-1,
|
|
null,
|
|
RuntimeTeleportHookPhase.None,
|
|
outcome));
|
|
progress.ReplayedDeferredChildCount++;
|
|
}
|
|
// Nothing left to restore on a full pass - releases the window.
|
|
_entities.ParentAttachments.RestoreDeferredCreates(
|
|
window, ReadOnlySpan<DeferredParentCreate>.Empty);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 5 R5-1: drains the accepted-relation queue keyed to this exact
|
|
/// guid, replaying every relation a standalone Parent continuation or
|
|
/// envelope CreateParent stage deferred because this parent was
|
|
/// unaddressable or named a not-yet-arrived incarnation. Mirrors
|
|
/// <see cref="ReplayDeferredChildren"/>'s detach-first / cancellation-
|
|
/// aware-window / contained-failure shape exactly - see that method's
|
|
/// remarks for the retail citations and the R4-1/R5-2 rationale, which
|
|
/// apply identically here.
|
|
/// </summary>
|
|
private bool ReplayDeferredAcceptedRelations(RuntimeEntityRecord canonical, Progress progress)
|
|
{
|
|
if (!_entities.IsCurrent(canonical))
|
|
return false;
|
|
|
|
ImmutableArray<DeferredAcceptedParentRelation> detached =
|
|
_entities.ParentAttachments.DetachDeferredAcceptedRelations(
|
|
canonical.ServerGuid, out DeferredReplayWindowToken window);
|
|
for (int index = 0; index < detached.Length; index++)
|
|
{
|
|
if (!_entities.IsCurrent(canonical))
|
|
{
|
|
_entities.ParentAttachments.RestoreDeferredAcceptedRelations(
|
|
window,
|
|
detached.AsSpan()[index..]);
|
|
return false;
|
|
}
|
|
|
|
DeferredAcceptedParentRelation entry = detached[index];
|
|
RuntimeParentRelationOutcome outcome;
|
|
try
|
|
{
|
|
outcome = ApplyReplayedParentRelation(canonical, entry);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
RecordReplayFailure(error);
|
|
outcome = RuntimeParentRelationOutcome.Rejected;
|
|
}
|
|
progress.Trace.Add(new RuntimeInitialCreateExecutedAction(
|
|
RuntimeInitialCreateExecutedActionKind.ParentRelationReplay,
|
|
0UL,
|
|
-1,
|
|
null,
|
|
RuntimeTeleportHookPhase.None,
|
|
null,
|
|
null,
|
|
RuntimePositionConstrainPhase.None,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
outcome));
|
|
if (outcome == RuntimeParentRelationOutcome.DeferredAwaitingParent)
|
|
{
|
|
// Relation still names an incarnation that has not arrived
|
|
// yet - wait for the NEXT one. Re-enqueues into a BRAND NEW
|
|
// queue instance (the whole bucket was already detached
|
|
// above), so this same detach loop never re-observes it.
|
|
_entities.ParentAttachments.EnqueueDeferredAcceptedRelation(entry);
|
|
}
|
|
}
|
|
_entities.ParentAttachments.RestoreDeferredAcceptedRelations(
|
|
window, ReadOnlySpan<DeferredAcceptedParentRelation>.Empty);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 5 R5-1: incarnation dispatch vs THIS parent (<paramref name="parent"/>),
|
|
/// mirroring <see cref="ParentAttachmentState.Resolve"/>'s own rules -
|
|
/// equal incarnation (or the envelope flavor, which has none to compare)
|
|
/// commits the attach; THIS parent newer than the relation discards it
|
|
/// (stale); the relation newer than THIS parent re-enqueues (wait for
|
|
/// the next incarnation - handled by the caller). The merge already
|
|
/// committed at the relation's ORIGINAL drain (its own position-
|
|
/// timestamp-only stamp); replay commits ONLY the attach tail, never
|
|
/// re-runs it.
|
|
/// </summary>
|
|
private RuntimeParentRelationOutcome ApplyReplayedParentRelation(
|
|
RuntimeEntityRecord parent,
|
|
in DeferredAcceptedParentRelation entry)
|
|
{
|
|
if (!_entities.TryGetActive(entry.ChildGuid, out RuntimeEntityRecord child)
|
|
|| child.Key != entry.ChildKey)
|
|
{
|
|
return RuntimeParentRelationOutcome.Rejected;
|
|
}
|
|
|
|
if (entry.ParentInstanceSequence is { } relationParentInstance
|
|
&& parent.Incarnation != relationParentInstance)
|
|
{
|
|
return PhysicsTimestampGate.IsNewer(relationParentInstance, parent.Incarnation)
|
|
? RuntimeParentRelationOutcome.DiscardedStaleParent
|
|
: RuntimeParentRelationOutcome.DeferredAwaitingParent;
|
|
}
|
|
|
|
// Round 5 R5-3 note: the child's OWN residence token (if its
|
|
// initial-tail is somehow still open at this exact moment) is not
|
|
// held here - only the parent's is in scope. AdvanceExecutorBaseline
|
|
// is deliberately SKIPPED (rebaseline: false) rather than guessed at;
|
|
// if the child's own residence is still active, its own next
|
|
// Complete() call will correctly observe this PositionAuthorityVersion
|
|
// bump as an external race and fail closed - the safe direction -
|
|
// rather than this call silently blessing a baseline it does not
|
|
// own.
|
|
CommitParentAttachment(child, default, rebaseline: false, buffer: null);
|
|
return RuntimeParentRelationOutcome.Applied;
|
|
}
|
|
|
|
private RuntimeInitialCreateExecutionStatus ApplyContinuation(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
RuntimeEntityKey key,
|
|
in RuntimeInitialCreateResidenceContinuation continuation,
|
|
in RuntimeInitialCreateExecutionInputs inputs,
|
|
Progress progress)
|
|
{
|
|
if (continuation.Kind
|
|
== RuntimeInitialCreateContinuationKind.SameIncarnationCreate)
|
|
{
|
|
return ApplyEnvelope(canonical, token, key, continuation, inputs, progress);
|
|
}
|
|
|
|
if (progress.PendingContinuationPlacement.IsValid)
|
|
{
|
|
RuntimeInitialCreateExecutionStatus resumeStatus =
|
|
ResumePendingPlacement(canonical, key, progress, out RuntimeAuthoritativePositionRoute route);
|
|
if (resumeStatus != RuntimeInitialCreateExecutionStatus.Completed)
|
|
return resumeStatus;
|
|
progress.Trace.Add(BuildPositionTrace(continuation.Sequence, -1, route));
|
|
return RuntimeInitialCreateExecutionStatus.Completed;
|
|
}
|
|
|
|
RuntimeInitialCreateTailAction action = continuation.Actions[0];
|
|
switch (continuation.Kind)
|
|
{
|
|
case RuntimeInitialCreateContinuationKind.ObjDesc:
|
|
if (!ApplyObjDescAction(canonical, token, action, null))
|
|
return Abandon(canonical, key);
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.ObjDesc,
|
|
continuation.Sequence));
|
|
return RuntimeInitialCreateExecutionStatus.Completed;
|
|
case RuntimeInitialCreateContinuationKind.Parent:
|
|
return ApplyParentContinuation(canonical, token, key, continuation, action, progress);
|
|
case RuntimeInitialCreateContinuationKind.Pickup:
|
|
if (!ApplyPickupAction(canonical, token, action, null))
|
|
return Abandon(canonical, key);
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.Pickup,
|
|
continuation.Sequence));
|
|
return RuntimeInitialCreateExecutionStatus.Completed;
|
|
case RuntimeInitialCreateContinuationKind.Movement:
|
|
if (!ApplyMovementAction(canonical, token, action, null))
|
|
return Abandon(canonical, key);
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.Movement,
|
|
continuation.Sequence));
|
|
return RuntimeInitialCreateExecutionStatus.Completed;
|
|
case RuntimeInitialCreateContinuationKind.State:
|
|
if (!ApplyStateAction(canonical, token, action, null))
|
|
return Abandon(canonical, key);
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.State,
|
|
continuation.Sequence));
|
|
return RuntimeInitialCreateExecutionStatus.Completed;
|
|
case RuntimeInitialCreateContinuationKind.Vector:
|
|
if (!ApplyVectorAction(canonical, token, action, null))
|
|
return Abandon(canonical, key);
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.Vector,
|
|
continuation.Sequence));
|
|
return RuntimeInitialCreateExecutionStatus.Completed;
|
|
case RuntimeInitialCreateContinuationKind.Position:
|
|
return ApplyPositionAction(
|
|
canonical,
|
|
token,
|
|
key,
|
|
continuation.Sequence,
|
|
-1,
|
|
action,
|
|
inputs,
|
|
progress,
|
|
null);
|
|
default:
|
|
throw new InvalidOperationException(
|
|
$"Unsupported initial-Create continuation kind {continuation.Kind}.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 3 B9 revalidated the standalone Parent continuation's parent
|
|
/// incarnation at EXECUTION time. Round 4 R4-5 replaced admission's
|
|
/// dead-letter re-Enqueue with a DISCARD; Round 5 R5-1 OVERTURNS that
|
|
/// discard with hard retail evidence: a missing/stale parent QUEUES the
|
|
/// raw blob under the PARENT's guid (standalone parent handler
|
|
/// 0x004535D0 -> <c>QueueBlobForObject</c>, pseudo-C 92326; GUID-keyed
|
|
/// placeholder bucket in <c>CObjectMaint</c>, 271082-271088) and replays
|
|
/// it via <c>ProcessObjectNetBlobs</c> when that guid is created - retail
|
|
/// NEVER discards on this path; its only check is pointer addressability
|
|
/// (92312). The already-accepted position-timestamp merge still runs
|
|
/// exactly once here (gate/snapshot lockstep preserved); the dispatch
|
|
/// that follows mirrors <see cref="ParentAttachmentState.Resolve"/>'s
|
|
/// OWN established staleness rules verbatim: unaddressable parent or a
|
|
/// relation naming a not-yet-arrived incarnation both ENQUEUE (wait);
|
|
/// only a relation whose named incarnation the LIVE parent has already
|
|
/// superseded is discarded. <see cref="ReplayDeferredAcceptedRelations"/>
|
|
/// drains the queue this enqueues into, in the target parent's own
|
|
/// initial tail, after its raw-Create replay.
|
|
/// </summary>
|
|
private RuntimeInitialCreateExecutionStatus ApplyParentContinuation(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
RuntimeEntityKey key,
|
|
in RuntimeInitialCreateResidenceContinuation continuation,
|
|
RuntimeInitialCreateTailAction action,
|
|
Progress progress)
|
|
{
|
|
ParentEvent.Parsed parentUpdate = action.Parent!.Value;
|
|
if (!ApplyParentPositionTimestampOnly(canonical, parentUpdate))
|
|
return Abandon(canonical, key);
|
|
|
|
RuntimeParentRelationOutcome outcome;
|
|
if (!_entities.TryGetActive(parentUpdate.ParentGuid, out RuntimeEntityRecord parent))
|
|
{
|
|
_entities.ParentAttachments.EnqueueDeferredAcceptedRelation(
|
|
canonical.ServerGuid, key, parentUpdate, null, action.AcceptedTimestamps);
|
|
outcome = RuntimeParentRelationOutcome.DeferredAwaitingParent;
|
|
}
|
|
else if (parent.Incarnation != parentUpdate.ParentInstanceSequence)
|
|
{
|
|
if (PhysicsTimestampGate.IsNewer(parentUpdate.ParentInstanceSequence, parent.Incarnation))
|
|
{
|
|
// Live parent is NEWER than the relation's named incarnation
|
|
// - stale, discard (Resolve's own discard branch).
|
|
outcome = RuntimeParentRelationOutcome.DiscardedStaleParent;
|
|
}
|
|
else
|
|
{
|
|
// Relation names a FUTURE incarnation - wait for it.
|
|
_entities.ParentAttachments.EnqueueDeferredAcceptedRelation(
|
|
canonical.ServerGuid, key, parentUpdate, null, action.AcceptedTimestamps);
|
|
outcome = RuntimeParentRelationOutcome.DeferredAwaitingParent;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
CommitParentAttachment(canonical, token, rebaseline: true, null);
|
|
outcome = RuntimeParentRelationOutcome.Applied;
|
|
}
|
|
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.Parent,
|
|
continuation.Sequence,
|
|
parentRelationOutcome: outcome));
|
|
return RuntimeInitialCreateExecutionStatus.Completed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The position-timestamp-only stamp that ALWAYS runs, exactly once,
|
|
/// when a standalone Parent continuation is first drained - regardless
|
|
/// of whether the dispatch that follows applies, defers, or discards
|
|
/// the relation. <see cref="InboundPhysicsStateController.ApplyAcceptedParent"/>
|
|
/// is exactly retail's <c>ApplyPositionTimestampOnly</c>. None of the
|
|
/// four executor-tracked baseline fields move here (Round 4 R4-4), so
|
|
/// no AdvanceExecutorBaseline call belongs here either.
|
|
/// </summary>
|
|
private bool ApplyParentPositionTimestampOnly(
|
|
RuntimeEntityRecord canonical,
|
|
ParentEvent.Parsed update)
|
|
{
|
|
if (!_entities.ApplyAcceptedParentSnapshot(
|
|
canonical.ServerGuid,
|
|
update,
|
|
out WorldSession.EntitySpawn stamped))
|
|
{
|
|
return false;
|
|
}
|
|
_entities.RefreshSnapshot(canonical, stamped);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The envelope's CreateParent stage revalidates parent ADDRESSABILITY
|
|
/// only - <see cref="CreateParentUpdate"/> carries no
|
|
/// <c>ParentInstanceSequence</c> at all (retail-notes.md's
|
|
/// <c>TryApplyCreateParent</c> remarks: "unlike standalone ParentEvent
|
|
/// it carries no parent INSTANCE_TS"), so there is no incarnation to
|
|
/// compare - only whether the parent is addressable at all. Round 5
|
|
/// R5-1: an unaddressable parent now QUEUES (same retail-faithful
|
|
/// deferral as the standalone Parent continuation), not discards - the
|
|
/// merge already ran once, unconditionally, before this dispatch.
|
|
/// </summary>
|
|
private (bool Success, RuntimeParentRelationOutcome Outcome) ApplyCreateParentContinuation(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
RuntimeEntityKey key,
|
|
RuntimeInitialCreateTailAction action,
|
|
List<PendingPublish>? buffer)
|
|
{
|
|
CreateParentUpdate createParentUpdate = action.CreateParent!.Value;
|
|
if (!ApplyCreateParentPositionTimestampOnly(canonical, createParentUpdate))
|
|
return (false, default);
|
|
|
|
if (!_entities.TryGetActive(createParentUpdate.ParentGuid, out _))
|
|
{
|
|
_entities.ParentAttachments.EnqueueDeferredAcceptedRelation(
|
|
canonical.ServerGuid, key, null, createParentUpdate, action.AcceptedTimestamps);
|
|
return (true, RuntimeParentRelationOutcome.DeferredAwaitingParent);
|
|
}
|
|
CommitParentAttachment(canonical, token, rebaseline: true, buffer);
|
|
return (true, RuntimeParentRelationOutcome.Applied);
|
|
}
|
|
|
|
/// <summary>Instance-seam-only stamp - see <see cref="ApplyParentPositionTimestampOnly"/>'s remarks.</summary>
|
|
private bool ApplyCreateParentPositionTimestampOnly(
|
|
RuntimeEntityRecord canonical,
|
|
CreateParentUpdate update)
|
|
{
|
|
if (!_entities.ApplyAcceptedCreateParentSnapshot(
|
|
canonical.ServerGuid,
|
|
update,
|
|
out WorldSession.EntitySpawn stamped))
|
|
{
|
|
return false;
|
|
}
|
|
_entities.RefreshSnapshot(canonical, stamped);
|
|
return true;
|
|
}
|
|
|
|
private RuntimeInitialCreateExecutionStatus ApplyEnvelope(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
RuntimeEntityKey key,
|
|
in RuntimeInitialCreateResidenceContinuation continuation,
|
|
in RuntimeInitialCreateExecutionInputs inputs,
|
|
Progress progress)
|
|
{
|
|
int startStage = progress.EnvelopeStageIndex < 0 ? 0 : progress.EnvelopeStageIndex;
|
|
|
|
if (progress.PendingContinuationPlacement.IsValid)
|
|
{
|
|
RuntimeInitialCreateExecutionStatus resumeStatus =
|
|
ResumePendingPlacement(canonical, key, progress, out RuntimeAuthoritativePositionRoute route);
|
|
if (resumeStatus != RuntimeInitialCreateExecutionStatus.Completed)
|
|
return resumeStatus;
|
|
progress.Trace.Add(BuildPositionTrace(continuation.Sequence, startStage, route));
|
|
startStage++;
|
|
progress.EnvelopeStageIndex = startStage;
|
|
}
|
|
|
|
for (int i = startStage; i < continuation.Actions.Length; i++)
|
|
{
|
|
if (!_entities.IsCurrent(canonical))
|
|
return Abandon(canonical, key);
|
|
|
|
RuntimeInitialCreateTailAction action = continuation.Actions[i];
|
|
switch (action.Kind)
|
|
{
|
|
case RuntimeInitialCreateTailActionKind.PreTailDescriptionAdaptation:
|
|
// AP-119 compatibility: retail does NOT re-run
|
|
// set_description for an equal-generation Create tail.
|
|
// The retained PhysicsSpawnData is a presentation-side
|
|
// compat artifact only; no canonical mutation here.
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.PreTailDescriptionAdaptation,
|
|
continuation.Sequence,
|
|
i));
|
|
break;
|
|
case RuntimeInitialCreateTailActionKind.ObjDesc:
|
|
if (!ApplyObjDescAction(canonical, token, action, progress.EnvelopeBuffer))
|
|
return Abandon(canonical, key);
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.ObjDesc,
|
|
continuation.Sequence,
|
|
i));
|
|
break;
|
|
case RuntimeInitialCreateTailActionKind.CreateParent:
|
|
{
|
|
(bool success, RuntimeParentRelationOutcome outcome) =
|
|
ApplyCreateParentContinuation(canonical, token, key, action, progress.EnvelopeBuffer);
|
|
if (!success)
|
|
return Abandon(canonical, key);
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.CreateParent,
|
|
continuation.Sequence,
|
|
i,
|
|
parentRelationOutcome: outcome));
|
|
break;
|
|
}
|
|
case RuntimeInitialCreateTailActionKind.Pickup:
|
|
if (!ApplyPickupAction(canonical, token, action, progress.EnvelopeBuffer))
|
|
return Abandon(canonical, key);
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.Pickup,
|
|
continuation.Sequence,
|
|
i));
|
|
break;
|
|
case RuntimeInitialCreateTailActionKind.Position:
|
|
{
|
|
progress.EnvelopeStageIndex = i;
|
|
RuntimeInitialCreateExecutionStatus status = ApplyPositionAction(
|
|
canonical,
|
|
token,
|
|
key,
|
|
continuation.Sequence,
|
|
i,
|
|
action,
|
|
inputs,
|
|
progress,
|
|
progress.EnvelopeBuffer);
|
|
if (status
|
|
== RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement)
|
|
{
|
|
// A mid-envelope yield publishes NOTHING - the
|
|
// buffer accumulated so far stays on Progress and is
|
|
// flushed only once the whole envelope completes.
|
|
return status;
|
|
}
|
|
if (status != RuntimeInitialCreateExecutionStatus.Completed)
|
|
return status;
|
|
break;
|
|
}
|
|
case RuntimeInitialCreateTailActionKind.Movement:
|
|
if (!ApplyMovementAction(canonical, token, action, progress.EnvelopeBuffer))
|
|
return Abandon(canonical, key);
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.Movement,
|
|
continuation.Sequence,
|
|
i));
|
|
break;
|
|
case RuntimeInitialCreateTailActionKind.State:
|
|
if (!ApplyStateAction(canonical, token, action, progress.EnvelopeBuffer))
|
|
return Abandon(canonical, key);
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.State,
|
|
continuation.Sequence,
|
|
i));
|
|
break;
|
|
case RuntimeInitialCreateTailActionKind.Vector:
|
|
if (!ApplyVectorAction(canonical, token, action, progress.EnvelopeBuffer))
|
|
return Abandon(canonical, key);
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.Vector,
|
|
continuation.Sequence,
|
|
i));
|
|
break;
|
|
case RuntimeInitialCreateTailActionKind.WeenieDescription:
|
|
if (!ApplyWeenieDescriptionAction(canonical, token, action, progress.EnvelopeBuffer))
|
|
return Abandon(canonical, key);
|
|
progress.Trace.Add(Simple(
|
|
RuntimeInitialCreateExecutedActionKind.WeenieDescription,
|
|
continuation.Sequence,
|
|
i));
|
|
break;
|
|
case RuntimeInitialCreateTailActionKind.ResidentCellCleanup:
|
|
{
|
|
RuntimeResidentCellCleanupDisposition? cleanupDisposition =
|
|
ApplyResidentCellCleanup(canonical);
|
|
if (cleanupDisposition is null)
|
|
{
|
|
// Round 3 B1: the fail-closed invariant violation
|
|
// (claimed+celless+not-deferred) is a typed
|
|
// abandonment, never a throw escaping Execute.
|
|
return Abandon(canonical, key);
|
|
}
|
|
progress.Trace.Add(new RuntimeInitialCreateExecutedAction(
|
|
RuntimeInitialCreateExecutedActionKind.ResidentCellCleanup,
|
|
continuation.Sequence,
|
|
i,
|
|
null,
|
|
RuntimeTeleportHookPhase.None,
|
|
null,
|
|
cleanupDisposition));
|
|
break;
|
|
}
|
|
default:
|
|
throw new InvalidOperationException(
|
|
$"Unsupported same-incarnation tail action {action.Kind}.");
|
|
}
|
|
|
|
// Round 3 B1: persist EnvelopeStageIndex after EVERY committed
|
|
// stage, not only Position. Without this, a retry after an
|
|
// unexpected mid-envelope failure (or any future non-Position
|
|
// yield point) would resume from a stale index and REPLAY
|
|
// stages already committed to the canonical snapshot -
|
|
// Position's own yield/resume already tracks this correctly;
|
|
// this makes every other stage kind do the same.
|
|
progress.EnvelopeStageIndex = i + 1;
|
|
}
|
|
|
|
// Retail's tail is one synchronous critical section; no observer
|
|
// boundary between stages. Publish every buffered per-stage event
|
|
// consecutively, in stage order, only now that every stage committed.
|
|
foreach (PendingPublish pending in progress.EnvelopeBuffer)
|
|
PublishNow(canonical, pending.Change, pending.Matches, pending.Cancellation);
|
|
progress.EnvelopeBuffer.Clear();
|
|
progress.EnvelopeStageIndex = -1;
|
|
return RuntimeInitialCreateExecutionStatus.Completed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 3 B4: matches
|
|
/// <see cref="RuntimeInitialCreateResidenceState.Complete"/>'s FULL
|
|
/// record/projection agreement rather than a subset of it - a
|
|
/// continuation's own authored placement deserves the same staleness
|
|
/// rigor as the initial lease's placement. Beyond the projection's own
|
|
/// reported facts, this also re-checks the LIVE canonical record's
|
|
/// PositionAuthorityVersion (has something ELSE moved the record since
|
|
/// this exact placement began?) and FullCellId/PlacementCommitVersion
|
|
/// (does the projection's committed cell/version still match reality?).
|
|
/// </summary>
|
|
private RuntimeInitialCreateExecutionStatus ResumePendingPlacement(
|
|
RuntimeEntityRecord canonical,
|
|
RuntimeEntityKey key,
|
|
Progress progress,
|
|
out RuntimeAuthoritativePositionRoute route)
|
|
{
|
|
route = progress.PendingContinuationRoute;
|
|
RuntimeEntityPlacementToken placementToken = progress.PendingContinuationPlacement;
|
|
if (_physics.SetPosition.IsPlacementCurrent(placementToken))
|
|
return RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement;
|
|
|
|
if (!_physics.SetPosition.TryPeekAcknowledgedPlacement(
|
|
placementToken,
|
|
out RuntimePlacementProjectionToken projection)
|
|
|| projection.Entity != placementToken.Entity
|
|
|| projection.SessionLifetimeVersion != placementToken.SessionLifetimeVersion
|
|
|| projection.PositionAuthorityVersion != placementToken.PositionAuthorityVersion
|
|
|| canonical.PositionAuthorityVersion != placementToken.PositionAuthorityVersion
|
|
|| projection.ExactCellId == 0u
|
|
|| projection.ExactCellId != canonical.FullCellId
|
|
|| projection.PlacementCommitVersion != canonical.PlacementCommitVersion)
|
|
{
|
|
// Round 4 R4-2: forget -> clear -> Abandon. Neither still in
|
|
// flight nor acknowledged with matching facts - cancelled or
|
|
// superseded by a newer authoritative operation. ForgetExactPlacement
|
|
// removes the retained _acknowledgedPlacementCompletions entry
|
|
// (via ForgetPlacementCompletionCore) even in this mismatch
|
|
// case - without it, HasRetainedCompletion for this key would
|
|
// stay true forever and block EVERY later placement begin (the
|
|
// runtime-surface.md 3.1 deadlock this executor exists to
|
|
// resolve). A newer owner now has the entity; abandon this
|
|
// execution.
|
|
RuntimePlacementCancellationReceipt forgotten =
|
|
_physics.SetPosition.ForgetExactPlacement(placementToken);
|
|
_physics.SetPosition.PublishCancellation(forgotten);
|
|
progress.PendingContinuationPlacement = default;
|
|
return Abandon(canonical, key);
|
|
}
|
|
|
|
if (!_physics.SetPosition.ConsumeAcknowledgedPlacement(placementToken, projection))
|
|
{
|
|
// Round 4 R4-2: same forget -> clear -> Abandon ordering - a
|
|
// concurrent consumer raced this exact acknowledgement away
|
|
// between TryPeek and here; still forget defensively so no
|
|
// stale watch/ack entry survives under this token.
|
|
RuntimePlacementCancellationReceipt forgotten =
|
|
_physics.SetPosition.ForgetExactPlacement(placementToken);
|
|
_physics.SetPosition.PublishCancellation(forgotten);
|
|
progress.PendingContinuationPlacement = default;
|
|
return Abandon(canonical, key);
|
|
}
|
|
|
|
progress.PendingContinuationPlacement = default;
|
|
progress.PendingContinuationSequence = 0UL;
|
|
return RuntimeInitialCreateExecutionStatus.Completed;
|
|
}
|
|
|
|
private RuntimeInitialCreateExecutionStatus ApplyPositionAction(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
RuntimeEntityKey key,
|
|
ulong sequence,
|
|
int stage,
|
|
RuntimeInitialCreateTailAction action,
|
|
in RuntimeInitialCreateExecutionInputs inputs,
|
|
Progress progress,
|
|
List<PendingPublish>? buffer)
|
|
{
|
|
if (!_residences.TryGetTransaction(canonical, out RuntimeInitialCreateResidenceLease lease)
|
|
|| canonical.Key != key)
|
|
{
|
|
return Abandon(canonical, key);
|
|
}
|
|
|
|
WorldSession.EntityPositionUpdate update = action.Position!.Value;
|
|
RuntimePositionEntityKind entityKind = EntityKindOf(lease.Route.OperationKind);
|
|
bool isLocalPlayer = entityKind is RuntimePositionEntityKind.LocalPlayer;
|
|
|
|
RuntimeAuthoritativePositionRoute route;
|
|
if (progress.PositionMergeCommittedForRetry)
|
|
{
|
|
// Round 3 B1: a PREVIOUS attempt already merged and published
|
|
// this exact position continuation; TryBeginExclusiveAuthoredPlacement
|
|
// failed on transient operation-slot contention rather than
|
|
// staleness, and this re-entry retries ONLY the placement
|
|
// begin. Re-running the merge here would double-publish.
|
|
route = progress.PendingContinuationRoute;
|
|
}
|
|
else
|
|
{
|
|
// Round 3 A3 / B5 and Round 4 R4-13 (contact from the retained
|
|
// wire packet's own IsGrounded bit only; the data-driven
|
|
// HasAnimations proxy with its PhysicsSpawnData fallback) now
|
|
// live in the ONE shared request builder, which C4 route 4a's
|
|
// remote classification also uses - see
|
|
// RuntimeAcceptedPositionRouteRequests for why a second
|
|
// hand-written copy of this construction is not allowed.
|
|
RuntimeAcceptedPositionRouteRequest request =
|
|
RuntimeAcceptedPositionRouteRequests.Build(
|
|
CurrentGeneration(),
|
|
canonical,
|
|
key,
|
|
update,
|
|
entityKind,
|
|
action.PositionSource,
|
|
action.PositionDisposition,
|
|
action.PreviousTeleportSequence,
|
|
action.AcceptedTimestamps.Teleport,
|
|
inputs.PlayerDistance,
|
|
inputs.UsePositionFromServer);
|
|
|
|
route = RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(request);
|
|
|
|
if (!route.Accepted)
|
|
{
|
|
// Round 3 B10: distinguish two retained-action shapes.
|
|
// When admission ITSELF already rejected (only
|
|
// FORCE_POSITION_TS could have moved), the ordinary Rejected
|
|
// merge is the correct stamp-only path. When admission
|
|
// ACCEPTED (Apply/ForcePosition - POSITION_TS/TELEPORT_TS/
|
|
// FORCE_POSITION_TS genuinely advanced) but EXECUTION-time
|
|
// classification now rejects, the snapshot must still
|
|
// reflect every channel the gate actually moved, not just
|
|
// ForcePosition.
|
|
bool stampedOk = action.PositionDisposition
|
|
is PositionTimestampDisposition.Rejected
|
|
? _entities.ApplyAcceptedPositionSnapshot(
|
|
canonical.ServerGuid,
|
|
update,
|
|
PositionTimestampDisposition.Rejected,
|
|
action.AcceptedTimestamps,
|
|
isLocalPlayer,
|
|
null,
|
|
null,
|
|
installPlacementFrame: false,
|
|
clearParent: false,
|
|
out WorldSession.EntitySpawn stampedOnly)
|
|
: _entities.ApplyAcceptedPositionExecutionRejectedSnapshot(
|
|
canonical.ServerGuid,
|
|
update.PositionSequence,
|
|
action.AcceptedTimestamps,
|
|
out stampedOnly);
|
|
if (!stampedOk)
|
|
return Abandon(canonical, key);
|
|
_entities.RefreshSnapshot(canonical, stampedOnly);
|
|
progress.Trace.Add(BuildPositionTrace(sequence, stage, route));
|
|
return RuntimeInitialCreateExecutionStatus.Completed;
|
|
}
|
|
|
|
// CANONICAL CELL SEMANTICS (deliberate difference from the
|
|
// legacy direct-commit InboundPhysicsStateController.TryApplyPosition
|
|
// caller): refreshPosition stays false. A wire position never
|
|
// directly makes the record resident; only a Runtime SetPosition
|
|
// commit (below) or a simulation full-cell commit may change
|
|
// FullCellId. This matches retail (HandleReceivedPosition never
|
|
// sets a resident cell) and the classifier's own documented rule
|
|
// that a target frame with a nonzero cell does not make a
|
|
// cellless canonical body resident. The snapshot's Position
|
|
// field itself IS refreshed; only the derived FullCellId write
|
|
// is withheld.
|
|
//
|
|
// Round 3 B6: installPlacementFrame/clearParent come from the
|
|
// classified route's OWN ApplyPlacementFrameBeforeRouting/
|
|
// UnparentBeforeRouting flags, not the legacy path's
|
|
// unconditional true/true.
|
|
PhysicsBody? body = canonical.PhysicsBody;
|
|
bool mergedOk = _entities.ApplyAcceptedPositionSnapshot(
|
|
canonical.ServerGuid,
|
|
update,
|
|
action.PositionDisposition,
|
|
action.AcceptedTimestamps,
|
|
isLocalPlayer,
|
|
body?.Orientation,
|
|
body?.Velocity,
|
|
installPlacementFrame: route.ApplyPlacementFrameBeforeRouting,
|
|
clearParent: route.UnparentBeforeRouting,
|
|
out WorldSession.EntitySpawn merged);
|
|
if (!mergedOk)
|
|
return Abandon(canonical, key);
|
|
_entities.RefreshSnapshot(canonical, merged, refreshPosition: false);
|
|
_entities.AdvancePositionAuthority(canonical);
|
|
_entities.ParentAttachments.EndChildProjection(canonical.ServerGuid);
|
|
// Round 3 B2: mutate -> rebaseline -> publish. Rebaselining
|
|
// BEFORE Publish closes the reentrant-retirement window a
|
|
// synchronous observer could otherwise see (the baseline would
|
|
// still show pre-mutation values while the observer reenters
|
|
// residence/executor state). Round 4 R4-4: only
|
|
// PositionAuthorityVersion moved (AdvancePositionAuthority also
|
|
// bumps VelocityAuthorityVersion, which is not one of the four
|
|
// executor-tracked baseline fields).
|
|
_residences.AdvanceExecutorBaseline(
|
|
canonical, token, RuntimeExecutorBaselineFields.PositionAuthorityVersion);
|
|
ulong positionVersion = canonical.PositionAuthorityVersion;
|
|
ulong spatialVersion = canonical.SpatialAuthorityVersion;
|
|
Publish(
|
|
canonical,
|
|
RuntimeEntityChange.Updated,
|
|
() => canonical.PositionAuthorityVersion == positionVersion
|
|
&& canonical.SpatialAuthorityVersion == spatialVersion,
|
|
default,
|
|
buffer);
|
|
|
|
if (!route.PerformsSetPosition)
|
|
{
|
|
// Interpolate / NoPositionOperation / AwaitFreshPosition:
|
|
// typed trace result only. Binding to the live
|
|
// interpolation owner is cutover work.
|
|
progress.Trace.Add(BuildPositionTrace(sequence, stage, route));
|
|
return RuntimeInitialCreateExecutionStatus.Completed;
|
|
}
|
|
|
|
progress.PositionMergeCommittedForRetry = true;
|
|
progress.PendingContinuationRoute = route;
|
|
progress.PositionMergeCommittedVersion = canonical.PositionAuthorityVersion;
|
|
}
|
|
|
|
RuntimeEntityPlacementToken placement = _physics.SetPosition
|
|
.TryBeginExclusiveAuthoredPlacement(
|
|
canonical,
|
|
canonical.PositionAuthorityVersion,
|
|
route.OperationKind);
|
|
if (!placement.IsValid)
|
|
{
|
|
// Round 3 B1: distinguish genuine staleness (abandon) from
|
|
// transient operation-slot contention (retry - the SAME merge
|
|
// stays committed; only the begin attempt repeats).
|
|
if (!_entities.IsCurrent(canonical)
|
|
|| canonical.PositionAuthorityVersion != progress.PositionMergeCommittedVersion)
|
|
{
|
|
progress.PositionMergeCommittedForRetry = false;
|
|
return Abandon(canonical, key);
|
|
}
|
|
return RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement;
|
|
}
|
|
if (!_physics.SetPosition.WatchPlacementCompletion(placement))
|
|
{
|
|
_ = _physics.SetPosition.ForgetExactPlacement(placement);
|
|
progress.PositionMergeCommittedForRetry = false;
|
|
return Abandon(canonical, key);
|
|
}
|
|
|
|
progress.PositionMergeCommittedForRetry = false;
|
|
progress.PendingContinuationPlacement = placement;
|
|
progress.PendingContinuationSequence = sequence;
|
|
progress.PendingContinuationRoute = route;
|
|
return RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement;
|
|
}
|
|
|
|
private bool ApplyObjDescAction(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
RuntimeInitialCreateTailAction action,
|
|
List<PendingPublish>? buffer)
|
|
{
|
|
if (!_entities.ApplyAcceptedObjDescSnapshot(
|
|
canonical.ServerGuid,
|
|
action.ObjDesc!.Value,
|
|
out WorldSession.EntitySpawn merged))
|
|
{
|
|
return false;
|
|
}
|
|
_entities.RefreshSnapshot(canonical, merged);
|
|
_entities.AdvanceObjDescAuthority(canonical);
|
|
// Round 4 R4-4: ObjDescAuthorityVersion is not one of the four
|
|
// executor-tracked baseline fields (PositionAuthorityVersion/
|
|
// CreateIntegrationVersion/FullCellId/PlacementCommitVersion) - no
|
|
// AdvanceExecutorBaseline call belongs here at all; calling it
|
|
// unconditionally would silently bless an external race on those
|
|
// four fields that this apply never touched.
|
|
ulong version = canonical.ObjDescAuthorityVersion;
|
|
Publish(
|
|
canonical,
|
|
RuntimeEntityChange.Updated,
|
|
() => canonical.ObjDescAuthorityVersion == version,
|
|
default,
|
|
buffer);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 5 R5-1: the SHARED attach-commit tail for BOTH the standalone
|
|
/// Parent continuation and the envelope CreateParent stage - the two
|
|
/// were byte-identical bodies before this round. The merge step that
|
|
/// precedes them (<c>ApplyAcceptedParentSnapshot</c>/
|
|
/// <c>ApplyAcceptedCreateParentSnapshot</c>, factored out into
|
|
/// <see cref="ApplyParentPositionTimestampOnly"/>/
|
|
/// <see cref="ApplyCreateParentPositionTimestampOnly"/>) runs EXACTLY
|
|
/// once at the relation's original drain and is deliberately
|
|
/// position-timestamp-only - it never sets ParentGuid/ParentLocation on
|
|
/// the snapshot. Per the test
|
|
/// StandaloneParentContinuationAppliesPositionTimestampOnlyMergeWithNoReDefer's
|
|
/// established, pre-Round-5 precedent (verified against it directly:
|
|
/// an earlier revision of this method wrongly called
|
|
/// <see cref="RuntimeEntityDirectory.TryCommitParent"/> here and broke
|
|
/// that test), the actual attach commit is out of scope for
|
|
/// this residence-continuation drain - it is the App-layer
|
|
/// <c>EquippedChildRenderController</c>'s job, invoked through
|
|
/// <see cref="RuntimeEntityObjectLifetime.TryCommitParent"/> only after
|
|
/// it validates the parent's render-side PartArray/holding-location can
|
|
/// actually host the child (see <c>LiveEntityRuntime.CommitStagedParent</c>'s
|
|
/// remarks). This method therefore commits ONLY the residence tail
|
|
/// (AdvancePositionAuthority/LeaveWorld/Forget/rebaseline/publish) and is
|
|
/// payload-agnostic, so it serves the live-continuation apply AND
|
|
/// <see cref="ReplayDeferredAcceptedRelations"/>'s replayed apply
|
|
/// identically - "apply through the SAME parent-apply body used by a
|
|
/// live Parent continuation" (round5-fixes.md R5-1) means exactly this
|
|
/// tail, not a new attach step neither live nor replay ever performed.
|
|
/// Unlike the legacy CommitPositionChannelUpdate helper, this does NOT
|
|
/// call ForgetInitialCreateResidence - the executor IS the residence
|
|
/// owner mid-drain; forgetting it here would cancel our own in-progress
|
|
/// lease. Residence teardown is exclusively the adoption/release
|
|
/// machinery's job (RunInitialTail / ConsumeExecuted).
|
|
/// <paramref name="rebaseline"/> is false ONLY at replay time, when the
|
|
/// child's own residence token is not held here - see
|
|
/// <see cref="ApplyReplayedParentRelation"/>'s remarks for why that is
|
|
/// safe (fails closed, never silently blesses a baseline it does not
|
|
/// own).
|
|
/// </summary>
|
|
private void CommitParentAttachment(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
bool rebaseline,
|
|
List<PendingPublish>? buffer)
|
|
{
|
|
_entities.AdvancePositionAuthority(canonical);
|
|
_physics.CollisionReports.LeaveWorld(canonical);
|
|
RuntimePlacementCancellationReceipt cancellation =
|
|
_physics.SetPosition.Forget(canonical);
|
|
// Round 3 B2: mutate -> rebaseline -> publish. Round 4 R4-4:
|
|
// AdvancePositionAuthority only moves PositionAuthorityVersion of
|
|
// the four tracked fields.
|
|
if (rebaseline)
|
|
{
|
|
_residences.AdvanceExecutorBaseline(
|
|
canonical, token, RuntimeExecutorBaselineFields.PositionAuthorityVersion);
|
|
}
|
|
ulong positionVersion = canonical.PositionAuthorityVersion;
|
|
ulong spatialVersion = canonical.SpatialAuthorityVersion;
|
|
Publish(
|
|
canonical,
|
|
RuntimeEntityChange.Updated,
|
|
() => canonical.PositionAuthorityVersion == positionVersion
|
|
&& canonical.SpatialAuthorityVersion == spatialVersion,
|
|
cancellation,
|
|
buffer);
|
|
}
|
|
|
|
private bool ApplyPickupAction(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
RuntimeInitialCreateTailAction action,
|
|
List<PendingPublish>? buffer)
|
|
{
|
|
if (!_entities.ApplyAcceptedPickupSnapshot(
|
|
canonical.ServerGuid,
|
|
action.Pickup!.Value,
|
|
out WorldSession.EntitySpawn merged))
|
|
{
|
|
return false;
|
|
}
|
|
_entities.RefreshSnapshot(canonical, merged);
|
|
// Retail: the object entered world (this residence's initial tail),
|
|
// then was picked up - a FIFO entry always executes AFTER the
|
|
// initial placement committed. This is a leave-world edge, but it
|
|
// must not tear down the residence mid-drain: only ordinary
|
|
// SetPosition.Forget runs here, never ForgetInitialCreateResidence.
|
|
_entities.AdvancePositionAuthority(canonical);
|
|
// D7 (route 7, architecture review A5): retail order is
|
|
// unset_parent @0x0045227F THEN leave_world @0x00452286
|
|
// (SmartBox::DoPickupEvent) - the same reorder
|
|
// RuntimeEntityObjectLifetime.TryApplyPickup applies to the live
|
|
// pickup path, applied here to the DORMANT replay of the same wire
|
|
// event so both pickup paths are one shape.
|
|
_entities.ParentAttachments.EndChildProjection(canonical.ServerGuid);
|
|
_physics.CollisionReports.LeaveWorld(canonical);
|
|
RuntimePlacementCancellationReceipt cancellation =
|
|
_physics.SetPosition.Forget(canonical);
|
|
_entities.SuspendObjectClock(canonical);
|
|
_entities.SetFullCell(canonical, 0u, 0u);
|
|
// Round 3 B2: mutate -> rebaseline -> publish. Round 4 R4-4:
|
|
// AdvancePositionAuthority + SetFullCell(0,0) move
|
|
// PositionAuthorityVersion and FullCellId, the only two of the
|
|
// four tracked fields this apply touches.
|
|
_residences.AdvanceExecutorBaseline(
|
|
canonical,
|
|
token,
|
|
RuntimeExecutorBaselineFields.PositionAuthorityVersion
|
|
| RuntimeExecutorBaselineFields.FullCellId);
|
|
ulong positionVersion = canonical.PositionAuthorityVersion;
|
|
ulong spatialVersion = canonical.SpatialAuthorityVersion;
|
|
Publish(
|
|
canonical,
|
|
RuntimeEntityChange.Withdrawn,
|
|
() => canonical.PositionAuthorityVersion == positionVersion
|
|
&& canonical.SpatialAuthorityVersion == spatialVersion,
|
|
cancellation,
|
|
buffer);
|
|
return true;
|
|
}
|
|
|
|
private bool ApplyMovementAction(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
RuntimeInitialCreateTailAction action,
|
|
List<PendingPublish>? buffer)
|
|
{
|
|
WorldSession.EntityMotionUpdate update = action.Movement!.Value;
|
|
// Safe to pass the retained wire's own MovementSequence directly
|
|
// here (unlike the legacy caller, which must read the live gate -
|
|
// see ApplyAcceptedMotion's remarks): a Movement continuation is
|
|
// only ever retained when AppliesMovementPayload || HasTimestampMutation,
|
|
// which structurally guarantees MOVEMENT_TS itself already advanced
|
|
// to this exact value at admission time.
|
|
if (!_entities.ApplyAcceptedMotionSnapshot(
|
|
canonical.ServerGuid,
|
|
update.MovementSequence,
|
|
action.AcceptedTimestamps.ServerControlledMove,
|
|
update,
|
|
retainPayload: false,
|
|
out WorldSession.EntitySpawn stamped))
|
|
{
|
|
return false;
|
|
}
|
|
_entities.RefreshSnapshot(canonical, stamped);
|
|
if (!action.AppliesMovementPayload)
|
|
{
|
|
// Timestamp-only entry: stamp landed above; no publish beyond
|
|
// that, matching legacy's own timestamp-only branch. Round 4
|
|
// R4-4: the stamp only moves MovementSequence/ServerControlSequence
|
|
// (nested Physics.Timestamps), never any of the four
|
|
// executor-tracked baseline fields - no AdvanceExecutorBaseline
|
|
// call here.
|
|
return true;
|
|
}
|
|
|
|
if (action.RetainMovementPayload)
|
|
{
|
|
if (!_entities.ApplyAcceptedMotionSnapshot(
|
|
canonical.ServerGuid,
|
|
update.MovementSequence,
|
|
action.AcceptedTimestamps.ServerControlledMove,
|
|
update,
|
|
retainPayload: true,
|
|
out WorldSession.EntitySpawn merged))
|
|
{
|
|
return false;
|
|
}
|
|
_entities.RefreshSnapshot(canonical, merged);
|
|
_entities.AdvanceMovementAuthority(canonical);
|
|
}
|
|
_entities.AdvanceMovementCommit(canonical);
|
|
// Round 4 R4-4: MovementAuthorityVersion/MovementCommitVersion are
|
|
// not among the four executor-tracked baseline fields - no
|
|
// AdvanceExecutorBaseline call belongs here.
|
|
ulong movementCommitVersion = canonical.MovementCommitVersion;
|
|
Publish(
|
|
canonical,
|
|
RuntimeEntityChange.Updated,
|
|
() => canonical.MovementCommitVersion == movementCommitVersion,
|
|
default,
|
|
buffer);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Round 4 R4-11: the BecameHidden branch's currency-failure path
|
|
/// returns <c>false</c> (routes the caller to the shared
|
|
/// <c>Abandon</c>/RejectedAuthority), not <c>true</c> as an earlier
|
|
/// revision of this method did - the legacy equivalent reports failure
|
|
/// there too, and the record genuinely mutated under us mid-apply.
|
|
/// Not independently unit-tested with a live reentrancy seam: this
|
|
/// harness has no constructible way to make
|
|
/// <c>RuntimeCollisionReportingState.LeaveWorld</c> invoke an observer
|
|
/// callback for a residence-fresh entity - <c>EndExpiredObjectCollisions</c>
|
|
/// returns immediately whenever <c>_owners</c> has no established
|
|
/// collision record for this key (see
|
|
/// <c>RuntimeCollisionReportingState.cs</c>'s own early-return guard),
|
|
/// which is always true for an entity that has never yet run a real
|
|
/// collision batch. Building a synthetic seam to force that callback
|
|
/// would be exactly the kind of workaround this project's CLAUDE.md
|
|
/// forbids; the fix is verified by direct code review of the
|
|
/// now-symmetric bool contract instead (every OTHER Apply*Action
|
|
/// method already returns false, never true, on its own currency
|
|
/// failure).
|
|
/// </summary>
|
|
private bool ApplyStateAction(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
RuntimeInitialCreateTailAction action,
|
|
List<PendingPublish>? buffer)
|
|
{
|
|
SetState.Parsed update = action.State!.Value;
|
|
if (!_entities.ApplyAcceptedStateSnapshot(
|
|
canonical.ServerGuid,
|
|
update,
|
|
out WorldSession.EntitySpawn merged))
|
|
{
|
|
return false;
|
|
}
|
|
_entities.RefreshSnapshot(canonical, merged);
|
|
RetailPhysicsStateTransition preview = RetailPhysicsStateTransitions.Apply(
|
|
canonical.FinalPhysicsState,
|
|
(PhysicsStateFlags)update.PhysicsState);
|
|
ulong priorPhysicsMutation = canonical.PhysicsStateMutationVersion;
|
|
if (preview.HiddenTransition is RetailHiddenTransition.BecameHidden)
|
|
{
|
|
_physics.CollisionReports.LeaveWorld(canonical);
|
|
if (!_entities.IsCurrent(canonical)
|
|
|| canonical.PhysicsStateMutationVersion != priorPhysicsMutation)
|
|
{
|
|
// Round 4 R4-11: the record mutated out from under us mid-apply
|
|
// (LeaveWorld's own synchronous collision-report callbacks can
|
|
// reenter and either invalidate currency or bump
|
|
// PhysicsStateMutationVersion again) - that IS an external
|
|
// race, not a successfully-applied continuation. Return false
|
|
// so the caller routes through the shared Abandon
|
|
// (RejectedAuthority), matching the legacy equivalent's
|
|
// failure report instead of silently claiming success.
|
|
return false;
|
|
}
|
|
}
|
|
RetailPhysicsStateTransition transition =
|
|
_entities.ApplyRawPhysicsState(canonical, update.PhysicsState);
|
|
if (canonical.Key is { } key)
|
|
{
|
|
_physics.Engine.ShadowObjects.UpdatePhysicsState(
|
|
key.LocalEntityId,
|
|
(uint)canonical.FinalPhysicsState);
|
|
}
|
|
// Round 4 R4-4: StateAuthorityVersion/PhysicsStateMutationVersion
|
|
// are not among the four executor-tracked baseline fields - no
|
|
// AdvanceExecutorBaseline call belongs here.
|
|
ulong stateVersion = canonical.StateAuthorityVersion;
|
|
ulong physicsMutationVersion = canonical.PhysicsStateMutationVersion;
|
|
Publish(
|
|
canonical,
|
|
transition.HiddenTransition is RetailHiddenTransition.BecameHidden
|
|
? RuntimeEntityChange.Hidden
|
|
: RuntimeEntityChange.Updated,
|
|
() => canonical.StateAuthorityVersion == stateVersion
|
|
&& canonical.PhysicsStateMutationVersion == physicsMutationVersion,
|
|
default,
|
|
buffer);
|
|
return true;
|
|
}
|
|
|
|
private bool ApplyVectorAction(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
RuntimeInitialCreateTailAction action,
|
|
List<PendingPublish>? buffer)
|
|
{
|
|
if (!_entities.ApplyAcceptedVectorSnapshot(
|
|
canonical.ServerGuid,
|
|
action.Vector!.Value,
|
|
out WorldSession.EntitySpawn merged))
|
|
{
|
|
return false;
|
|
}
|
|
_entities.RefreshSnapshot(canonical, merged);
|
|
_entities.AdvanceVectorAuthority(canonical);
|
|
// Round 4 R4-4: VectorAuthorityVersion is not among the four
|
|
// executor-tracked baseline fields - no AdvanceExecutorBaseline
|
|
// call belongs here.
|
|
ulong version = canonical.VectorAuthorityVersion;
|
|
Publish(
|
|
canonical,
|
|
RuntimeEntityChange.Updated,
|
|
() => canonical.VectorAuthorityVersion == version,
|
|
default,
|
|
buffer);
|
|
return true;
|
|
}
|
|
|
|
private bool ApplyWeenieDescriptionAction(
|
|
RuntimeEntityRecord canonical,
|
|
in RuntimeInitialCreateResidenceToken token,
|
|
RuntimeInitialCreateTailAction action,
|
|
List<PendingPublish>? buffer)
|
|
{
|
|
// Round 3 A2: this must NOT be a wholesale RefreshSnapshot of the
|
|
// raw retained packet - it merges exactly like every other
|
|
// same-generation Create (MergeUntimestampedCreate via the instance
|
|
// seam), keeping the retained Position/appearance/physics-timestamp
|
|
// fields earlier stages already committed to _snapshots.
|
|
if (!_entities.ApplyAcceptedWeenieDescriptionSnapshot(
|
|
canonical.ServerGuid,
|
|
action.WeenieDescription!.Value,
|
|
out WorldSession.EntitySpawn merged))
|
|
{
|
|
return false;
|
|
}
|
|
_entities.RefreshSnapshot(canonical, merged, refreshPosition: false);
|
|
// RuntimeEntityObjectLifetime.RegisterEntityCore's ExistingGeneration
|
|
// branch only calls Entities.AdvanceCreateAuthority when
|
|
// !beginInitialResidence - a residence-pending entity's admission
|
|
// deliberately skipped it. This deferred WeenieDescription tail
|
|
// action is where that authority mutation actually lands.
|
|
_entities.AdvanceCreateAuthority(canonical);
|
|
ulong createVersion = canonical.CreateIntegrationVersion;
|
|
// Round 3 B12: RuntimeLiveEntitySessionController.OnSpawned (the
|
|
// non-residence direct-host Create path) drives
|
|
// ApplyAcceptedSpawn(canonical, integrationVersion, canonical.Snapshot,
|
|
// replaceGeneration: NewGeneration) for EVERY accepted Create - the
|
|
// prior "zero callers" claim for this object-table wiring was false.
|
|
// This tail action is the residence path's exact counterpart: it
|
|
// only ever runs for an ExistingGeneration same-incarnation Create
|
|
// (a residence is admitted only when preview is ExistingGeneration),
|
|
// so replaceGeneration is always false here.
|
|
//
|
|
// Round 4 R4-3: order is AdvanceCreateAuthority -> AdvanceExecutorBaseline
|
|
// -> _applyAcceptedSpawn -> (false -> typed Abandon) -> buffered
|
|
// publish. Rebaselining BEFORE the object-table apply (rather than
|
|
// after, as an earlier revision did) means a reentrant callback
|
|
// FROM WITHIN _applyAcceptedSpawn's own synchronous
|
|
// ObjectAdded/ObjectUpdated dispatch already observes a current
|
|
// baseline. _applyAcceptedSpawn's own result is now actually
|
|
// OBSERVED rather than discarded: RuntimeEntityObjectLifetime.
|
|
// ApplyAcceptedSpawn re-checks currency before, during, AND after
|
|
// its own object-table apply (its callback is synchronous and may
|
|
// re-enter entity lifetime) - a nested replacement racing in from
|
|
// that same callback invalidates the exact canonical incarnation
|
|
// this drain is still executing against, mirroring
|
|
// RuntimeLiveEntitySessionController.cs:87's own gate on that same
|
|
// call's result. The remaining tail cannot run against a record a
|
|
// nested replacement has already superseded.
|
|
_residences.AdvanceExecutorBaseline(
|
|
canonical,
|
|
token,
|
|
RuntimeExecutorBaselineFields.PositionAuthorityVersion
|
|
| RuntimeExecutorBaselineFields.CreateIntegrationVersion);
|
|
if (!_applyAcceptedSpawn(canonical, createVersion, merged, /* replaceGeneration: */ false))
|
|
return false;
|
|
Publish(
|
|
canonical,
|
|
RuntimeEntityChange.Updated,
|
|
() => canonical.CreateIntegrationVersion == createVersion,
|
|
default,
|
|
buffer);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail: SmartBox::HandleCreateObject's same-incarnation tail, final
|
|
/// step (retail-notes.md function 1, 0x00454c80, lines ~788-801) -
|
|
/// <c>objcell_id != 0 && cell == 0</c> marks for destruction,
|
|
/// <c>objcell_id != 0 && cell != 0</c> un-marks, and no cell
|
|
/// claimed with no weenie also marks for destruction. Asserts the
|
|
/// invariant rather than building a new destruction mechanism: any
|
|
/// claimed-but-celless outcome must already be under lost-cell/deferred
|
|
/// SetPosition ownership.
|
|
/// </summary>
|
|
private RuntimeResidentCellCleanupDisposition? ApplyResidentCellCleanup(
|
|
RuntimeEntityRecord canonical)
|
|
{
|
|
uint claimedCell = canonical.Snapshot.Physics?.Position?.LandblockId
|
|
?? canonical.Snapshot.Position?.LandblockId
|
|
?? 0u;
|
|
if (claimedCell == 0u)
|
|
{
|
|
// No cell claimed. See RuntimeResidentCellCleanupDisposition.
|
|
// CelllessNoWeenieMarkUnreachable's remarks: retail's matching
|
|
// no-weenie destruction-mark condition is structurally
|
|
// unreachable through this exact envelope path.
|
|
return RuntimeResidentCellCleanupDisposition
|
|
.CelllessNoWeenieMarkUnreachable;
|
|
}
|
|
if (canonical.FullCellId != 0u)
|
|
return RuntimeResidentCellCleanupDisposition.ResidentUnmarked;
|
|
if (!_physics.SetPosition.IsDeferred(canonical))
|
|
{
|
|
// Round 3 B1: the fail-closed invariant violation
|
|
// (claimed+celless+not-deferred) is a typed abandonment - null
|
|
// signals the caller to Abandon rather than letting an
|
|
// exception escape Execute.
|
|
return null;
|
|
}
|
|
// Claimed but celless, and an existing lost-cell/deferred
|
|
// SetPosition operation already owns this exact entity: the
|
|
// destruction mark belongs to that existing lifetime (retail:
|
|
// AddObjectToBeDestroyed was already reached via that path), not to
|
|
// this tail action - assert the invariant, do not invent a second
|
|
// destruction mechanism.
|
|
return RuntimeResidentCellCleanupDisposition.DeferredUnderLostCellOwnership;
|
|
}
|
|
|
|
private void Publish(
|
|
RuntimeEntityRecord canonical,
|
|
RuntimeEntityChange change,
|
|
Func<bool> matches,
|
|
RuntimePlacementCancellationReceipt cancellation,
|
|
List<PendingPublish>? buffer)
|
|
{
|
|
if (buffer is not null)
|
|
{
|
|
// Buffered (same-incarnation envelope) publishes flush only
|
|
// after EVERY stage has committed. The per-field "matches"
|
|
// check the IMMEDIATE (standalone-continuation) path uses
|
|
// exists to catch a reentrant race between one mutation and
|
|
// its own publish - but a LATER stage in the SAME envelope
|
|
// legitimately advances the SAME field again as normal,
|
|
// expected progression (e.g. WeenieDescription's
|
|
// AdvanceCreateAuthority bumps Position/State/Vector/ObjDesc
|
|
// authority all at once), which would make an EARLIER stage's
|
|
// captured matches() go stale by flush time even though
|
|
// nothing external raced it. IsCurrent (checked unconditionally
|
|
// by PublishNow below) is the only currency guard a buffered
|
|
// entry needs: envelope processing dispatches no event until
|
|
// the flush, so there is no opportunity for reentrancy mid-
|
|
// envelope except at a Position-stage yield, and THAT window is
|
|
// independently guarded by ApplyEnvelope's own IsCurrent check
|
|
// at the top of the resumed loop and by ResumePendingPlacement.
|
|
buffer.Add(new PendingPublish(change, static () => true, cancellation));
|
|
return;
|
|
}
|
|
PublishNow(canonical, change, matches, cancellation);
|
|
}
|
|
|
|
private void PublishNow(
|
|
RuntimeEntityRecord canonical,
|
|
RuntimeEntityChange change,
|
|
Func<bool> matches,
|
|
RuntimePlacementCancellationReceipt cancellation)
|
|
{
|
|
_physics.SetPosition.PublishCancellation(cancellation);
|
|
if (_entities.IsCurrent(canonical) && matches())
|
|
_events.PublishEntity(change, canonical);
|
|
}
|
|
|
|
private static RuntimeInitialCreateExecutedAction BuildPositionTrace(
|
|
ulong sequence,
|
|
int stage,
|
|
in RuntimeAuthoritativePositionRoute route) => new(
|
|
RuntimeInitialCreateExecutedActionKind.Position,
|
|
sequence,
|
|
stage,
|
|
route.Disposition,
|
|
route.TeleportHookPhase,
|
|
null,
|
|
null,
|
|
// Round 3 B6: record the route's own flags in the trace.
|
|
route.ConstrainPhase,
|
|
route.StopInterpolating,
|
|
route.ZeroVelocity,
|
|
route.PreserveHeading,
|
|
route.SendPositionImmediately,
|
|
route.UnparentBeforeRouting);
|
|
|
|
private static RuntimeInitialCreateExecutedAction Simple(
|
|
RuntimeInitialCreateExecutedActionKind kind,
|
|
ulong sequence,
|
|
int stage = -1,
|
|
RuntimeParentRelationOutcome? parentRelationOutcome = null) => new(
|
|
kind,
|
|
sequence,
|
|
stage,
|
|
null,
|
|
RuntimeTeleportHookPhase.None,
|
|
ParentRelationOutcome: parentRelationOutcome);
|
|
|
|
private static RuntimePositionEntityKind EntityKindOf(
|
|
RuntimeSetPositionOperationKind operationKind) => operationKind switch
|
|
{
|
|
RuntimeSetPositionOperationKind.InitialLogin
|
|
or RuntimeSetPositionOperationKind.LocalAuthoritative =>
|
|
RuntimePositionEntityKind.LocalPlayer,
|
|
RuntimeSetPositionOperationKind.ProjectileAuthoritative =>
|
|
RuntimePositionEntityKind.Projectile,
|
|
_ => RuntimePositionEntityKind.Remote,
|
|
};
|
|
|
|
private RuntimeGenerationToken CurrentGeneration() => _generation?.Invoke() ?? default;
|
|
}
|