From 5db3de3c7ab2c6350d11af7f34b852464fc1e0f9 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Aug 2026 03:49:56 +0200 Subject: [PATCH] feat(runtime): execute initial placement continuations The admission checkpoint (30012361) sealed accepted updates behind a pending initial placement; nothing could apply them, so AcknowledgeAdoption refused any non-empty FIFO and the residence system had no path to completion. RuntimeInitialCreateContinuationExecutor is that missing mechanism: a synchronous, retry-idempotent Execute transaction that adopts the acknowledged initial placement exactly once (consuming the retained completion so later authored placements for the key can begin), emits the AfterEnterWorld hook request for the local player, replays deferred missing-parent raw Creates and queued parent relations by parent GUID (retail ProcessObjectNetBlobs order: whole-bucket detach, FIFO dispatch, cancellation-aware restore), and drains the mixed continuation FIFO strictly by sequence with retail route decisions taken at execution time via ClassifyAcceptedPosition on live inputs (server-asserted wire contact, data-driven animation proxy, live distance/options). Apply bodies are shared with the legacy fused paths through new gate-less instance seams on InboundPhysicsStateController that keep the one snapshot store in lockstep; SameIncarnationCreate envelopes apply atomically with per-stage idempotency and buffered publication after the final stage; every abandonment path retires the residence through the lifetime choke point and converges the ownership ledger (executor progress, deferred buckets, replay windows, placement watches all folded into IsConverged). Position/placement side effects are exactly-once under retry, external mutations are detected via a field-masked executor baseline, and AwaitingContinuationPlacement yields keep the FIFO head retryable. Production routes are deliberately untouched: graphical and headless Create still use legacy RegisterEntity, and no host calls Execute. The cutover is the next checkpoint; AP-1/AD-1 remain open until it lands. Register rows AD-59/AD-60/AP-130/AP-131/AP-132/TS-62/TS-63 document the slice's deviations in this commit. Reviewed: retail-conformance PASS + architecture/adversarial PASS after five implementation rounds (wire-contact source, snapshot lockstep, WeenieDescription merge, abandonment convergence, reentrant retirement windows, acknowledged-completion leak, baseline precision, replay containment/restore, queue-by-parent-GUID relation deferral all fixed at root cause). Runtime tests 903/903; complete Release solution 10,696 passed / 4 intentional skips; focused executor gate 161/161. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 13 +- .../Entities/InboundPhysicsStateController.cs | 589 ++- .../Entities/ParentAttachmentState.cs | 350 +- .../Entities/RuntimeEntityDirectory.cs | 95 + .../Entities/RuntimeEntityObjectLifetime.cs | 87 +- ...untimeInitialCreateContinuationExecutor.cs | 2107 ++++++++ .../RuntimeInitialCreateResidenceState.cs | 313 +- .../InboundPhysicsStateControllerTests.cs | 70 + ...eInitialCreateContinuationExecutorTests.cs | 4464 +++++++++++++++++ 9 files changed, 8002 insertions(+), 86 deletions(-) create mode 100644 src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs create mode 100644 tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 0e5decb4..35d2cf94 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -62,7 +62,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 44 active rows +## 2. Adaptation (AD) — 46 active rows (AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -144,10 +144,12 @@ readiness/requeue adaptation. See | AD-45 | App teardown can overlap a newer `INSTANCE_TS` record after retiring the old active identity. `TargetManager` therefore retains the exact target host and each `TargettedVoyeurInfo` retains the exact watcher host; unsubscribe, Sticky live-target reads, inbound sender validation, and ExitWorld delivery compare/use those pointer-like tokens rather than resolving a reused GUID. Retail stores only GUIDs because `DeleteObject` finishes `exit_world`/`leave_world` while the retiring `CPhysicsObj` remains the sole object-table entry. | `src/AcDream.Core/Physics/Motion/TargetManager.cs`; `StickyManager.cs`; `TargettedVoyeurInfo.cs`; `IPhysicsObjHost` exact relationship seams | This preserves retail's effective object-pointer identity while allowing App resource teardown to fail and retry without blocking an accepted newer server generation. Ordinary `GetObjectA` remains active-record-only, so tombstones cannot accept new relationships. | If any target/voyeur path bypasses the exact token, retrying an old teardown can remove or notify a newer same-GUID relationship, or Sticky can steer toward the replacement; retained tokens also keep the small manager graph alive until teardown converges. | `CPhysicsObj::exit_world @ 0x00514E60`; `CObjectMaint::DeleteObject(CPhysicsObj*) @ 0x00508460`; `ACCObjectMaint::DeleteObject(uint) @ 0x005576F0`; `TargetManager::SetTarget @ 0x0051AC30`; `ClearTarget @ 0x0051A7E0`; `AddVoyeur @ 0x0051A830`; `RemoveVoyeur @ 0x0051AD90` | | AD-57 | **Re-argued from TS-24 at Campaign P P7 (2026-07-30).** Outbound `RawMotionState.Actions` is always empty at runtime. The packer emits `num_actions` + per-action pairs (L.2b, `RawMotionState::Pack` 0x0051ed10) and the R3-W1 action FIFO capability exists (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`); no production input path ENQUEUES autonomous actions yet because the emote/autonomous-motion feature surface is unimplemented. An empty list is byte-identical to retail's own no-pending-actions state, so this is a feature gap, not a divergence of existing behavior. | packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`; FIFO `src/AcDream.Core/Physics/RawMotionState.cs` | Every currently-shipped movement packet matches retail byte-shape; the gap only manifests when emote-class autonomous actions are implemented. | When emotes land, forgetting to route them through the FIFO would silently drop them from the wire. | `RawMotionState::Pack` 0x0051ed10 | | AD-58 | **Re-argued from TS-40 at Campaign P P7 (2026-07-30).** Retail's `physics_obj->cell` null test ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` and `RemoteMotion` construction, consumed by `CMotionInterp`'s detached-object link-strip guards. Equivalence: every acdream body that would have a null retail cell pointer has `InWorld == false` (bodies exist only for world entities; the flag flips exactly at placement/withdrawal), so the guards fire on the same population. A structural adaptation of retail's pointer-as-state idiom to acdream's explicit-flag idiom, not scheduled debt. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | If a future path creates a body before world placement without clearing `InWorld`, the link-strip guards misfire where retail's null-cell test would not. | `CMotionInterp` link-strip guards raw @305xxx | +| AD-59 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The `SameIncarnationCreate` envelope buffers one publish per committed stage and flushes them ALL, in stage order, only after the LAST stage commits (constant-true per-field predicate, `IsCurrent`-checked at flush - the per-field closure variant was invalidated by WeenieDescription's six-field `AdvanceCreateAuthority`). A subscriber sees N back-to-back events with no interleaved observation point, each carrying the FINAL merged post-envelope record state, not per-stage state. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyEnvelope` buffered-publish tail; `Publish`/`PublishNow`) | Retail's own tail is one synchronous critical section, and retail emits ONE notice per Create (`ECM_Physics::SendNotice_CreateObject`, fired whenever a weenie exists, independent of the physics-registration outcome) - never N per-internal-step notices. The buffered flush is closer to retail's one-signal model than per-step publication would be, though not a literal 1:1 match. | A subscriber diffing consecutive `Updated` events from the SAME envelope to isolate one stage's delta gets every stage's cumulative state on each event - silently wrong incremental-diff logic, not a crash. | `SmartBox::HandleCreateObject` 0x00454C80 same-incarnation tail (one synchronous critical section); `ACCObjectMaint::CreateObject` 0x00558870 step 11 (`ECM_Physics::SendNotice_CreateObject`) | +| AD-60 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** Executor Position-continuation merges never directly commit residency: `ApplyPositionAction` refreshes `canonical.Snapshot.Position` with the retained wire pose but withholds the derived `FullCellId` (`RefreshSnapshot(..., refreshPosition: false)`); only a Runtime `SetPosition` commit (the continuation's own classified placement) or a later simulation full-cell commit may change residency. The LEGACY immediate-apply path's `RefreshSnapshot(canonical, snapshot, refreshPosition: acceptedPosition)` (`RuntimeEntityObjectLifetime.cs:1338`) still derives `FullCellId` from bare wire acceptance - that coarser rule is part of the AP-1 divergence this campaign is removing, not something this row blesses. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, the CANONICAL CELL SEMANTICS comment) | Matches retail exactly: `HandleReceivedPosition` never writes a resident cell - `enter_world`/`MoveOrTeleport`'s placement commit and `SetPosition` do; also matches the classifier's documented cellless rule. | If a future change passes `refreshPosition: true` here, a wire Position would make a cellless canonical body resident without any placement/collision commit - the classic AP-1-shaped bug this campaign exists to close. | `SmartBox::HandleReceivedPosition` 0x00453FD0; `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition` comment | --- -## 3. Documented approximation (AP) — 87 active rows (AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 90 active rows (AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -264,8 +266,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-126 | One monotonic Stopwatch-backed clock (`TransportClock`) drives every transport gate (2.0 s ack, 0.6 s NAK, 0.333 s handshake retry, 0.5 s interval, 5 s assembler sweep); retail splits gates between `Timer::cur_time` (server-adjusted) and `Timer::local_time`. | `src/AcDream.Core.Net/Transport/TransportClock.cs` | The cur/local split only matters for gates that must track server clock adjustments; none of the ported gates semantically depend on server time — they are local cadences. A single injectable source also gives the virtual-clock test seam every conformance suite relies on. | A future port of a genuinely server-clock-relative gate could silently use the wrong clock if it reuses TransportClock without checking this row. | `SharedNet::EnqueuePak @ 0x00543B10` (cur_time); `ClientNet::ProcessConnection @ 0x00545450` (local_time for the 140 s check) | | ~~AP-127~~ | **RETIRED 2026-07-31 (#268).** `PlayerSkillMath` now owns retail `CACQualities::InqSkill` ordering for both panel values and Runtime run/jump prediction: intrinsic + positive 0x16D all-skills + the exact +10 category switch, then `EnchantSkill`, then 0x146 Jack of All Trades +5 and specialized-only `2 × 0x158`. Live player PropertyInt changes refresh the immutable Runtime augmentation snapshot. The separately described current-stamina local-copy nuance was re-audited: the query reads current stamina, but ordinary max-vital buffs target the max-secondary key and do not create stamina when current is zero; no independently observable residual remains. | `src/AcDream.Core/Player/PlayerSkillMath.cs`; `src/AcDream.Core/Player/LocalPlayerState.cs`; `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs`; `src/AcDream.Runtime/Session/LiveSessionEventRouter.cs` | — | — | `CACQualities::InqSkill @ 0x00592660`; `CACQualities::InqRunRate @ 0x00592800`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0` | | AP-128 | **PK-timer jump-cost clock basis unconfirmed** (filed at the P3 Opus review, 2026-07-30): `PlayerWeenie.JumpStaminaCost` evaluates retail's 20-second PK-recency window (`LastPkAttackTimestamp` PropertyFloat 0x91 + 20.0 >= now) against `Environment.TickCount64` process-uptime seconds. The magnitude argument is sound (a 32-bit float cannot hold a Unix epoch with sub-second precision — a conformance test caught the ±128 s swallow), but the wire timestamp's own basis is the SERVER's, so a cross-base compare is latent. INERT today: ACE models neither property, so `_lastPkAttackTimestamp` is never pushed and the branch never fires. | `src/AcDream.Core/Physics/PlayerWeenie.cs` (`JumpStaminaCost` remarks) | Branch unreachable against every ACE-family server; non-PK cost is bit-identical to pre-P3. The basis question is cdb-answerable (`Timer::cur_time` epoch) if a PK server is ever targeted. | Against a hypothetical server that sends PropertyFloat 0x91, the PK cost bump fires arbitrarily (always/never) instead of on the 20-second window. | `CACQualities::JumpStaminaCost 0x00591b90` pc 412934-412968; `Timer::cur_time`; stat-coupled pseudocode doc §12b | +| AP-130 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The classifier's `HasAnimations` input is the static proxy `(Snapshot.MotionTableId ?? Snapshot.Physics?.MotionTableId) != 0` - "does the Create carry a nonzero motion table" - uniformly for every position source. Retail's `HasAnims` bit is live animation-QUEUE non-emptiness (`CSequence::has_anims` = `anim_list.head_ != 0`), which can differ from mere table assignment. The only confirmed retail `HasAnims` call site on this path is inside `HandleReceivedPosition` itself. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`, `hasAnimations` local) | Best static proxy available without wiring a live animation-queue read into presentation-independent Position classification; deterministic and testable; gates only `ApplyPlacementFrameBeforeRouting` (placement-FRAME install), never pose or cell placement. | An entity with an assigned motion table but an empty animation queue (or vice versa) gets the wrong placement-frame decision - a one-frame animation-blend glitch on a Position-driven correction where retail would have done the opposite. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `HasAnims` gate, pseudo-C ~92992); `CPhysicsObj::HasAnims` 0x0050F770 -> `CSequence::has_anims` 0x00524BD0 | +| AP-131 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** The legacy Position merge (`TryApplyPosition`, today's ONLY production Position wire caller) passes `installPlacementFrame: true, clearParent: true` to the shared `ApplyAcceptedPosition` body - byte-identical to its pre-refactor unconditional behavior. Retail gates `SetPlacementFrame` on `!HasAnims` and skips `unset_parent`/`SetPlacementFrame` entirely on the FORCE_POSITION early return (Gate A); the continuation executor's caller threads the classified route's real flags and is retail-exact. | `src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs` (`TryApplyPosition` call site) | Exact pre-existing production behavior, deliberately unchanged by the executor slice; the retail-gated behavior exists in the same shared body and is exercised by the executor's tests. The legacy caller is deleted at the production cutover, retiring this row by construction. | Until cutover, an animated entity's ordinary Position update installs a placement frame retail would skip (animation snap/reset), and a ForcePosition on a parented entity unparents where retail's Gate A never reaches `unset_parent`. | `SmartBox::HandleReceivedPosition` 0x00453FD0 (the `!HasAnims` `SetPlacementFrame` gate ~92992; the FORCE_POSITION early return ~92932 before `unset_parent` ~92990) | +| AP-132 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** acdream gates queued parent relations on parent INCARNATION where retail's queue-by-GUID replay is pointer-only. Retail queues a missing-parent relation blob under the PARENT's GUID (`QueueBlobForObject` ~92326; GUID-keyed `CObjectMaint` placeholder bucket ~271082-271088) and replays it on GUID (re)creation with only an addressability check (~92312) - no PARENT INSTANCE_TS comparison anywhere on that path (retail's only instance check there is on the CHILD, ~92316-92317). acdream additionally compares the relation's `ParentInstanceSequence` at admission (pre-existing `TryApplyParent`/`Resolve` rules) and at executor replay (`ApplyReplayedParentRelation`): live-parent-newer discards, relation-newer stays queued for an exact match. The replay's child-missing arm also drops where retail would re-queue under the child's GUID; child-scoped bucket filtering (`RemoveObject`/`RemoveChild`) proactively covers the same ledger tradeoff. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyReplayedParentRelation`); `RuntimeEntityObjectLifetime.cs` (`TryApplyParent` admission gate); `ParentAttachmentState.cs` (`Resolve` staleness rules) | The wire event names a SPECIFIC parent incarnation (`ParentEvent.Parsed.ParentInstanceSequence`) - the gate honors data the server explicitly sent. acdream's own established admission-time rules (`ParentAttachmentState.Resolve`, predating this slice) already fixed incarnation-gating as the project's parent-staleness posture; the replay path only extends that SAME posture for consistency. | Server GUID reuse between admission and replay: retail would attach the old queued relation to whatever NEW object now holds the GUID (retail's own recycling quirk); acdream discards it (parent newer) or leaves it queued (parent older) - silent loss of a relation retail would have applied, tied to server GUID-recycling cadence, not ordinary play. | Standalone parent handler 0x004535D0 (~92310-92326); `CObjectMaint::QueueBlobForObject` 0x005092D0 (~271082-271088); child instance check ~92316-92317 | -## 4. Temporary stopgap (TS) — 34 active rows (TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 36 active rows (TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState`, and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -309,6 +314,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-60 | No 140 s dead-link declaration or referral auto-reconnect in the transport; a silent server is only visible through `LinkStatusSnapshot.SecondsSinceLastPacket` (presentational). | `src/AcDream.Core.Net/Transport/ReliableTransport.cs`; `src/AcDream.Core.Net/WorldSession.cs` (`BuildLinkStatus`) | The input (seconds since last inbound) is already exposed; session lifecycle/reconnect is Runtime's ownership domain and deserves its own campaign rather than a transport-embedded side effect. Every ACE transport death is silence, so nothing server-side depends on the client reacting at 140 s. | A dead link idles until the user acts; no automatic recall/referral reconnect where retail would attempt one. | `ClientNet::ProcessConnection @ 0x00545450` tail (the two 140.0 literals) | | TS-61 | A UDP send failure burns the reliable sequence and its ISAAC word (the encode commits before `_net.Send`); retail keeps the sealed packet at the queue head and retries with the same key. | `src/AcDream.Core.Net/Transport/OutboundFlowQueue.cs` (`SendGameMessage`) | A connectionless-socket `SendTo` failure is effectively unreachable in practice (no route/ICMP errors surface on later receives, not sends, on Windows UDP); recovering it faithfully needs a full outbound packet queue. The N1 review accepted the exposure explicitly. | One `SocketException` on send would desync the outbound cipher permanently (session death; observable as `[net-out-EX]` followed by silence). | `FlowQueue::TransmitNewPackets @ 0x00547C2C` (retry-from-head) | | TS-56 | Chase-camera mouse input retains acdream's invented post-filter yaw/pitch scalars (`0.004`/`0.003` radians per count), and held-key pitch/zoom retain their non-retail integration shapes. Retail mouse look passes `FilterMouseInput(delta) × configured sensitivity × 1/15` as the replacement scale to `CameraSet::Rotate`, which then applies the shared 8° angle; retail held pitch uses the same angle and zoom scales the viewer offset multiplicatively. | `src/AcDream.App/Input/CameraPointerInputController.cs`; `src/AcDream.App/Input/MouseLookController.cs`; `src/AcDream.App/Rendering/CameraFrameController.cs` | Slice 8 is behavior-preserving ownership work. The named-retail audit proves the mismatch but has not yet extracted the configured mouse-sensitivity default or the exact caller flags needed for a complete feel port; changing only one scalar here would create a mixed input model. | RMB/MMB orbit, held pitch, and zoom can feel slower, faster, or differently accelerated than retail even though callback ordering and filtering are correct. | `CameraSet::Rotate @ 0x00458310`; `CameraSet::MouseLookHandler` call at `0x00458EF9`; `CameraSet::Raise @ 0x00457B00`; `CameraSet::Closer @ 0x004586D0`; `docs/research/2026-06-11-holistic-map/wf2-camera-viewer.md` | +| TS-62 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** NO Position route in the dormant executor runs a live `ConstrainTo` binding - including the `SetPosition`/`SetPositionSimple` routes. `RuntimeAuthoritativePositionRoute.ConstrainPhase` (None/Before/After) is classified for EVERY accepted route and recorded into the execution trace, but the constrain-before-vs-after distinction exists purely as classified metadata pending a live binding at the production cutover. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyPositionAction`/`BuildPositionTrace`); `RuntimeAuthoritativePositionRouteClassifier.cs` (`ConstrainPhase`) | Host-cutover work with no Runtime-side owner to bind to yet; the canonical snapshot's Position IS refreshed on every accepted route, so the fact is retained - only the live constrain/smoothing behavior is deferred. The trace carries the exact phase a host must bind. | Until a host wires it, ANY Position continuation applies its raw pose with no constrain-distance clamp or smoothing - a visible pop instead of retail's constrained correction, on exactly the entities created while an authored placement was in flight. | `SmartBox::HandleReceivedPosition` 0x00453FD0, the three `ConstrainTo` sites (~93007 remote-after, ~93024 teleport-after, ~93041 local-ordinary-before) | +| TS-63 | **Filed 2026-08-02 (physics campaign, continuation-executor slice).** `ApplyResidentCellCleanup`'s three branches: (1) claimed-cell + celless + NOT under lost-cell/deferred ownership - retail's genuine `AddObjectToBeDestroyed` case - has no safe Runtime destruction owner yet, so the executor performs a typed ABANDONMENT (`RejectedAuthority`) instead of destroying; (2) claimed + celless + deferred returns `DeferredUnderLostCellOwnership` - retail's destruction bookkeeping for this exact entity is already owned by the lost-cell/deferred `SetPosition` lifetime (a statement, not a parallel mechanism); (3) claimedCell==0 returns `CelllessNoWeenieMarkUnreachable` and is NOT a divergence - every admitted envelope structurally carries a WeenieDescription (`HasValidShape`), so retail's no-weenie destruction alternative is unreachable through this construction. | `src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs` (`ApplyResidentCellCleanup`; the Abandon conversion in `ApplyEnvelope`) | No production caller yet; every branch is typed and test-observable; building a parallel destruction mechanism ahead of the object-table/lost-cell cutover wiring would be the exact workaround class CLAUDE.md forbids - failing closed is the honest interim. | Branch (1): a genuinely claimed-but-celless-undeferred entity aborts the drain and SURVIVES where retail destroys it, until the cutover wiring lands. Branch (3): a future envelope construction without a WeenieDescription would break the premise and needs re-examination. | `SmartBox::HandleCreateObject` 0x00454C80 tail (~93933 destruction mark; ~93942-93943 un-mark/no-weenie) | --- diff --git a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs index 8f7674f0..ac548d0e 100644 --- a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs +++ b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs @@ -114,6 +114,48 @@ public sealed class InboundPhysicsStateController return false; } + accepted = ApplyAcceptedObjDesc(old, update); + _snapshots[update.Guid] = accepted; + return true; + } + + /// + /// Shared ObjDesc snapshot mutation. A retained residence continuation was + /// only ever enqueued after the exact same + /// call already + /// succeeded at admission time, so the retained update's own + /// IS the stamped gate + /// value; the executor must not re-derive it from a live gate. + /// + /// + /// Instance seam for : reads + /// _snapshots[guid] as the merge base and writes the result back, + /// keeping this store and the continuation executor's canonical + /// RuntimeEntityRecord.Snapshot in lockstep (Round 3 A1). Without + /// this seam the executor merged directly against the record's own + /// snapshot and never touched _snapshots, so the FIRST later + /// legacy TryApplyXxx call would re-merge onto a stale base and + /// silently revert every drained continuation. + /// + internal bool ApplyAcceptedObjDescSnapshot( + uint guid, + ObjDescEvent.Parsed update, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedObjDesc(old, update); + _snapshots[guid] = accepted; + return true; + } + + internal static WorldSession.EntitySpawn ApplyAcceptedObjDesc( + WorldSession.EntitySpawn old, + ObjDescEvent.Parsed update) + { PhysicsSpawnData? physics = old.Physics; if (physics is { } desc) physics = desc with @@ -121,7 +163,7 @@ public sealed class InboundPhysicsStateController Timestamps = desc.Timestamps with { ObjDesc = update.ObjDescSequence }, }; - accepted = old with + return old with { AnimPartChanges = update.ModelData.AnimPartChanges, TextureChanges = update.ModelData.TextureChanges, @@ -129,8 +171,6 @@ public sealed class InboundPhysicsStateController BasePaletteId = update.ModelData.BasePaletteId, Physics = physics, }; - _snapshots[update.Guid] = accepted; - return true; } public bool TryApplyPickup( @@ -145,11 +185,33 @@ public sealed class InboundPhysicsStateController return false; } - accepted = ApplyUnparentedPosition(old, null, update.PositionSequence); + accepted = ApplyAcceptedPickup(old, update); _snapshots[update.Guid] = accepted; return true; } + internal static WorldSession.EntitySpawn ApplyAcceptedPickup( + WorldSession.EntitySpawn old, + PickupEvent.Parsed update) => + ApplyUnparentedPosition(old, null, update.PositionSequence); + + /// Instance seam for - see the + /// remarks on . + internal bool ApplyAcceptedPickupSnapshot( + uint guid, + PickupEvent.Parsed update, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedPickup(old, update); + _snapshots[guid] = accepted; + return true; + } + /// /// Applies the parent branch embedded in a same-generation PhysicsDesc. /// Unlike standalone ParentEvent it carries no parent INSTANCE_TS, so only @@ -167,11 +229,33 @@ public sealed class InboundPhysicsStateController return false; } - accepted = ApplyPositionTimestampOnly(child, update.ChildPositionSequence); + accepted = ApplyAcceptedCreateParent(child, update); _snapshots[update.ChildGuid] = accepted; return true; } + internal static WorldSession.EntitySpawn ApplyAcceptedCreateParent( + WorldSession.EntitySpawn child, + CreateParentUpdate update) => + ApplyPositionTimestampOnly(child, update.ChildPositionSequence); + + /// Instance seam for - + /// see the remarks on . + internal bool ApplyAcceptedCreateParentSnapshot( + uint guid, + CreateParentUpdate update, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedCreateParent(old, update); + _snapshots[guid] = accepted; + return true; + } + public bool TryApplyParent( ParentEvent.Parsed update, out WorldSession.EntitySpawn accepted) @@ -186,11 +270,33 @@ public sealed class InboundPhysicsStateController return false; } - accepted = ApplyPositionTimestampOnly(child, update.ChildPositionSequence); + accepted = ApplyAcceptedParent(child, update); _snapshots[update.ChildGuid] = accepted; return true; } + internal static WorldSession.EntitySpawn ApplyAcceptedParent( + WorldSession.EntitySpawn child, + ParentEvent.Parsed update) => + ApplyPositionTimestampOnly(child, update.ChildPositionSequence); + + /// Instance seam for - see the + /// remarks on . + internal bool ApplyAcceptedParentSnapshot( + uint guid, + ParentEvent.Parsed update, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedParent(old, update); + _snapshots[guid] = accepted; + return true; + } + public bool TryCommitParent( uint childGuid, uint parentGuid, @@ -235,17 +341,28 @@ public sealed class InboundPhysicsStateController update.MovementSequence, update.ServerControlSequence); timestamps = Current(gate); - WorldSession.EntitySpawn stamped = MirrorGateTimestamps(old, gate) with - { - MovementSequence = gate.MovementTimestamp, - ServerControlSequence = gate.ServerControlledMoveTimestamp, - }; - _snapshots[update.Guid] = stamped; // Retail consumes MOVEMENT_TS before it discovers that the - // SERVER_CONTROLLED_MOVE_TS is stale. Preserve that timestamp-only - // mutation in the canonical snapshot even though no motion payload - // is applied. + // SERVER_CONTROLLED_MOVE_TS is stale (PhysicsTimestampGate. + // TryAcceptMovementEvent checks MOVEMENT_TS first and always advances + // it before the SERVER_CONTROLLED_MOVE_TS check can fail). Preserve + // that timestamp-only mutation in the canonical snapshot even though + // no motion payload is applied. Stamp from the GATE's post-call + // values, not the wire's own proposed values: TryAcceptMovementEvent + // has THREE rejection flavors (bad instance; stale MOVEMENT_TS; stale + // SERVER_CONTROLLED_MOVE_TS) and only the last one actually advances + // MOVEMENT_TS. Stamping update.MovementSequence unconditionally would + // move the snapshot to a rejected packet's value in the first two + // flavors - gate.MovementTimestamp is a no-op there and correct in + // the third, exactly like this method's legacy predecessor. + WorldSession.EntitySpawn stamped = ApplyAcceptedMotion( + old, + gate.MovementTimestamp, + gate.ServerControlledMoveTimestamp, + update, + retainPayload: false); + _snapshots[update.Guid] = stamped; + if (!applyPayload) { accepted = default; @@ -258,6 +375,76 @@ public sealed class InboundPhysicsStateController return true; } + accepted = ApplyAcceptedMotion( + stamped, + gate.MovementTimestamp, + gate.ServerControlledMoveTimestamp, + update, + retainPayload: true); + _snapshots[update.Guid] = accepted; + return true; + } + + /// + /// Shared Movement snapshot mutation. The top-level and nested + /// Movement/ServerControlledMove timestamp fields are ALWAYS stamped to + /// exactly / + /// (regardless of ); only the actual + /// movement payload (raw bytes/MotionState) is gated on it. This + /// deliberately does NOT mirror every OTHER timestamp channel from a + /// live gate the way the legacy path's old MirrorGateTimestamps + /// helper did (redundant there, since gate and snapshot stay in + /// lockstep on the immediate-apply path, but wrong for the executor's + /// out-of-band residence replay, where other channels may have advanced + /// far beyond what THIS retained continuation is allowed to observe). + /// + /// The movement/server-control VALUES are explicit inputs, not derived + /// from internally - one shared apply body, + /// two different sources of truth for its two callers. The legacy + /// immediate-apply caller MUST pass the live gate's own post-call + /// MovementTimestamp/ServerControlledMoveTimestamp + /// (PhysicsTimestampGate.TryAcceptMovementEvent has three rejection + /// flavors - bad instance, stale MOVEMENT_TS, stale + /// SERVER_CONTROLLED_MOVE_TS - and only reading the gate AFTER the call + /// is a no-op in the first two and correct in the third; the wire's own + /// proposed + /// would silently corrupt the snapshot to a rejected packet's value in + /// the first two flavors). The continuation executor instead passes the + /// retained action's own Movement.Value.MovementSequence/ + /// - safe + /// there specifically because a Movement continuation is only ever + /// retained when AppliesMovementPayload || HasTimestampMutation, + /// which structurally guarantees MOVEMENT_TS itself already advanced to + /// that exact wire value at admission time (the same three-flavor gate + /// logic that makes stamping the wire value unsafe for an UNGATED legacy + /// call makes it exactly correct for an ALREADY-GATED retained one). + /// + internal static WorldSession.EntitySpawn ApplyAcceptedMotion( + WorldSession.EntitySpawn old, + ushort movementSequence, + ushort acceptedServerControlledMove, + WorldSession.EntityMotionUpdate update, + bool retainPayload) + { + PhysicsSpawnData? stampedPhysics = old.Physics; + if (stampedPhysics is { } stampedDesc) + stampedPhysics = stampedDesc with + { + Timestamps = stampedDesc.Timestamps with + { + Movement = movementSequence, + ServerControlledMove = acceptedServerControlledMove, + }, + }; + WorldSession.EntitySpawn stamped = old with + { + MovementSequence = movementSequence, + ServerControlSequence = acceptedServerControlledMove, + Physics = stampedPhysics, + }; + if (!retainPayload) + return stamped; + PhysicsSpawnData? physics = stamped.Physics; if (physics is { } desc) physics = desc with @@ -266,19 +453,37 @@ public sealed class InboundPhysicsStateController ReadOnlyMemory.Empty, update.MotionState, update.IsAutonomous), - Timestamps = desc.Timestamps with - { - Movement = gate.MovementTimestamp, - ServerControlledMove = gate.ServerControlledMoveTimestamp, - }, }; - accepted = stamped with + return stamped with { MotionState = update.MotionState, Physics = physics, }; - _snapshots[update.Guid] = accepted; + } + + /// Instance seam for - see the + /// remarks on . + internal bool ApplyAcceptedMotionSnapshot( + uint guid, + ushort movementSequence, + ushort acceptedServerControlledMove, + WorldSession.EntityMotionUpdate update, + bool retainPayload, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedMotion( + old, + movementSequence, + acceptedServerControlledMove, + update, + retainPayload); + _snapshots[guid] = accepted; return true; } @@ -293,6 +498,15 @@ public sealed class InboundPhysicsStateController return false; } + accepted = ApplyAcceptedVector(old, update); + _snapshots[update.Guid] = accepted; + return true; + } + + internal static WorldSession.EntitySpawn ApplyAcceptedVector( + WorldSession.EntitySpawn old, + VectorUpdate.Parsed update) + { PhysicsSpawnData? physics = old.Physics; if (physics is { } desc) physics = desc with @@ -302,8 +516,23 @@ public sealed class InboundPhysicsStateController Timestamps = desc.Timestamps with { Vector = update.VectorSequence }, }; - accepted = old with { Physics = physics }; - _snapshots[update.Guid] = accepted; + return old with { Physics = physics }; + } + + /// Instance seam for - see the + /// remarks on . + internal bool ApplyAcceptedVectorSnapshot( + uint guid, + VectorUpdate.Parsed update, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedVector(old, update); + _snapshots[guid] = accepted; return true; } @@ -318,6 +547,15 @@ public sealed class InboundPhysicsStateController return false; } + accepted = ApplyAcceptedState(old, update); + _snapshots[update.Guid] = accepted; + return true; + } + + internal static WorldSession.EntitySpawn ApplyAcceptedState( + WorldSession.EntitySpawn old, + SetState.Parsed update) + { PhysicsSpawnData? physics = old.Physics; if (physics is { } desc) physics = desc with @@ -326,12 +564,27 @@ public sealed class InboundPhysicsStateController Timestamps = desc.Timestamps with { State = update.StateSequence }, }; - accepted = old with + return old with { PhysicsState = update.PhysicsState, Physics = physics, }; - _snapshots[update.Guid] = accepted; + } + + /// Instance seam for - see the + /// remarks on . + internal bool ApplyAcceptedStateSnapshot( + uint guid, + SetState.Parsed update, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + accepted = ApplyAcceptedState(old, update); + _snapshots[guid] = accepted; return true; } @@ -339,6 +592,20 @@ public sealed class InboundPhysicsStateController /// Returns true when the addressed live incarnation exists, even when the /// position payload is rejected. This lets callers publish a freshly /// consumed FORCE_POSITION_TS without applying a stale pose. + /// + /// Round 4 R4-15: this legacy immediate-apply path has no HasContact or + /// route-classification concept at all - it merges unconditionally on + /// the retained alone. The + /// continuation executor's ApplyPositionAction instead runs + /// RuntimeAuthoritativePositionRouteClassifier and derives + /// contact solely from the retained wire packet's own + /// IsGrounded bit. This is internal refactor debt tracked for + /// the eventual cutover unification (this file's TryApplyPosition + /// is today's only PRODUCTION Position wire caller; the classifier-based + /// path is test-only until a host wires the executor) - it is NOT a + /// retail divergence and does not belong in + /// docs/architecture/retail-divergence-register.md. See docs/ISSUES.md + /// for the tracked follow-up. /// public bool TryApplyPosition( WorldSession.EntityPositionUpdate update, @@ -370,12 +637,153 @@ public sealed class InboundPhysicsStateController gate, teleportAdvanced: disposition is PositionTimestampDisposition.Apply && advancesTeleport); - if (disposition is PositionTimestampDisposition.Rejected) + accepted = ApplyAcceptedPosition( + old, + update, + disposition, + timestamps, + isLocalPlayer, + forcePositionRotation, + currentLocalVelocity, + // Legacy immediate-apply reproduces EXACT prior behavior: the + // placement frame and parent clear were always unconditional + // here (see the Round 3 A1/B6 admission handoff). Only the + // continuation executor threads the classified route's own + // ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting flags. + installPlacementFrame: true, + clearParent: true); + _snapshots[update.Guid] = accepted; + return true; + } + + /// Instance seam for - see + /// the remarks on . The + /// executor passes its classified route's own + /// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting + /// flags rather than the legacy path's unconditional true/true. + internal bool ApplyAcceptedPositionSnapshot( + uint guid, + WorldSession.EntityPositionUpdate update, + PositionTimestampDisposition disposition, + AcceptedPhysicsTimestamps timestamps, + bool isLocalPlayer, + System.Numerics.Quaternion? forcePositionRotation, + System.Numerics.Vector3? currentLocalVelocity, + bool installPlacementFrame, + bool clearParent, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) { - accepted = MirrorGateTimestamps(old, gate); - _snapshots[update.Guid] = accepted; - return true; + accepted = default; + return false; } + accepted = ApplyAcceptedPosition( + old, + update, + disposition, + timestamps, + isLocalPlayer, + forcePositionRotation, + currentLocalVelocity, + installPlacementFrame, + clearParent); + _snapshots[guid] = accepted; + return true; + } + + /// + /// Round 3 B10: a retained Position action whose ADMISSION-time + /// disposition was Apply/ForcePosition (the gate genuinely advanced + /// POSITION_TS/TELEPORT_TS/FORCE_POSITION_TS at admission) but whose + /// EXECUTION-time classification rejects (RejectedAuthority/RejectedData + /// from the live route classifier - e.g. a malformed live input) must + /// still stamp every timestamp channel the gate actually moved; it must + /// not silently freeze the snapshot at pre-admission values. Distinct + /// from , which is the + /// ADMISSION-time-gate-rejected case where only FORCE_POSITION_TS can + /// have moved - here Position, Teleport, AND ForcePosition are all + /// replayed, still without installing any pose/parent/placement field. + /// + internal bool ApplyAcceptedPositionExecutionRejectedSnapshot( + uint guid, + ushort acceptedPositionSequence, + AcceptedPhysicsTimestamps timestamps, + out WorldSession.EntitySpawn accepted) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn old)) + { + accepted = default; + return false; + } + PhysicsSpawnData? physics = old.Physics; + if (physics is { } desc) + physics = desc with + { + Timestamps = desc.Timestamps with + { + Position = acceptedPositionSequence, + Teleport = timestamps.Teleport, + ForcePosition = timestamps.ForcePosition, + }, + }; + accepted = old with + { + PositionSequence = acceptedPositionSequence, + Physics = physics, + }; + _snapshots[guid] = accepted; + return true; + } + + /// + /// Shared Position snapshot mutation, reconstructed from the RETAINED + /// disposition + accepted-gate facts rather than a live gate read (the + /// executor drains a Position continuation long after the timestamp gate + /// itself moved on to later packets). Mirrors + /// SmartBox::HandleReceivedPosition (0x00453FD0) / + /// PositionPack::UnPack (0x00516740) exactly as the legacy + /// immediate-apply path did. + /// + /// A retained + /// continuation only ever exists because + /// -adjacent + /// bookkeeping mutated (see : + /// a Rejected outcome always leaves POSITION_TS and TELEPORT_TS net + /// unchanged, so the ONLY dimension that can differ is FORCE_POSITION_TS + /// from the local-player force-position fallthrough branch) — apply the + /// timestamp-only mutation and nothing else. + /// + /// For Apply/ForcePosition, the retained wire's own + /// IS the + /// stamped POSITION_TS value (PhysicsTimestampGate always sets the stored + /// channel to exactly the incoming value on acceptance); Teleport and + /// ForcePosition stamps come from the retained + /// captured at admission time. + /// + /// / + /// (Round 3 B6) let the two callers reproduce two different retail + /// gates: the legacy immediate-apply path always passes true/true + /// (retail's HandleReceivedPosition unconditionally runs + /// unset_parent/SetPlacementFrame there), while the continuation + /// executor passes its classified route's own + /// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting - + /// both false only for the FORCE_POSITION branch, which retail's + /// MoveOrTeleport returns from immediately, BEFORE either call. + /// + internal static WorldSession.EntitySpawn ApplyAcceptedPosition( + WorldSession.EntitySpawn old, + WorldSession.EntityPositionUpdate update, + PositionTimestampDisposition disposition, + AcceptedPhysicsTimestamps timestamps, + bool isLocalPlayer, + System.Numerics.Quaternion? forcePositionRotation, + System.Numerics.Vector3? currentLocalVelocity, + bool installPlacementFrame, + bool clearParent) + { + if (disposition is PositionTimestampDisposition.Rejected) + return ApplyAcceptedPositionTimestampOnly(old, timestamps); CreateObject.ServerPosition appliedPosition = update.Position; if (disposition is PositionTimestampDisposition.ForcePosition @@ -392,9 +800,14 @@ public sealed class InboundPhysicsStateController // PositionPack::UnPack (0x00516740) initializes an absent placement // id to zero; HandleReceivedPosition (0x00453FD0) forwards that exact - // value to SetPlacementFrame on a normal accepted update. - uint? appliedPlacement = disposition is PositionTimestampDisposition.Apply - ? update.PlacementId ?? 0u + // value to SetPlacementFrame on a normal accepted update - but only + // when the caller's route actually runs that step + // (installPlacementFrame; retail skips it entirely while HasAnimations + // is true). + uint? appliedPlacement = installPlacementFrame + ? (disposition is PositionTimestampDisposition.Apply + ? update.PlacementId ?? 0u + : old.PlacementId) : old.PlacementId; System.Numerics.Vector3? appliedVelocity = disposition switch @@ -407,7 +820,7 @@ public sealed class InboundPhysicsStateController // A fresh local teleport explicitly installs zero velocity. A // normal local correction does not consume PositionPack velocity. PositionTimestampDisposition.Apply when isLocalPlayer => - advancesTeleport + timestamps.TeleportAdvanced ? System.Numerics.Vector3.Zero : currentLocalVelocity ?? old.Physics?.Velocity, @@ -418,6 +831,22 @@ public sealed class InboundPhysicsStateController _ => old.Physics?.Velocity, }; + // Round 4 R4-12: FORCE_POSITION_TS's retail Gate A + // (retail-notes.md function 3, SmartBox::HandleReceivedPosition + // 0x00453FD0, "GATE A: local-player force-position self-echo + // shortcut") returns BEFORE CPhysicsObj::unset_parent ever runs - + // clearParent stays false on that route (Round 3 B6), so a + // ForcePosition-merged snapshot can legitimately carry BOTH a + // non-null Position (the force-applied pose, below) AND a non-null + // ParentGuid/ParentLocation/Physics.Parent (the retained + // attachment) simultaneously. This combined shape is deliberate, + // not a bug: every OTHER accepted disposition either clears the + // parent (a genuine unparented Position) or never touches Position + // at all (a Parent/CreateParent-only merge) - ForcePosition is the + // one case that does both without touching parent state at all. + uint? parentGuid = clearParent ? null : old.ParentGuid; + uint? parentLocation = clearParent ? null : old.ParentLocation; + PhysicsAttachment? physicsParent = clearParent ? null : old.Physics?.Parent; PhysicsSpawnData? physics = old.Physics; if (physics is { } desc) physics = desc with @@ -425,26 +854,47 @@ public sealed class InboundPhysicsStateController Position = appliedPosition, AnimationFrame = appliedPlacement, Velocity = appliedVelocity, - Parent = null, + Parent = physicsParent, Timestamps = desc.Timestamps with { - Position = gate.PositionTimestamp, - Teleport = gate.TeleportTimestamp, - ForcePosition = gate.ForcePositionTimestamp, + Position = update.PositionSequence, + Teleport = timestamps.Teleport, + ForcePosition = timestamps.ForcePosition, }, }; - accepted = old with + return old with { Position = appliedPosition, - PositionSequence = gate.PositionTimestamp, - ParentGuid = null, - ParentLocation = null, + PositionSequence = update.PositionSequence, + ParentGuid = parentGuid, + ParentLocation = parentLocation, PlacementId = appliedPlacement, Physics = physics, }; - _snapshots[update.Guid] = accepted; - return true; + } + + /// + /// The Rejected-disposition-but-mutated branch of + /// : only FORCE_POSITION_TS can have + /// legitimately moved (see that method's remarks). Deliberately narrower + /// than the legacy path's old full-gate mirror, which was only safe + /// in-lockstep and is unsound for the executor's out-of-band replay. + /// + private static WorldSession.EntitySpawn ApplyAcceptedPositionTimestampOnly( + WorldSession.EntitySpawn old, + AcceptedPhysicsTimestamps timestamps) + { + PhysicsSpawnData? physics = old.Physics; + if (physics is { } desc) + physics = desc with + { + Timestamps = desc.Timestamps with + { + ForcePosition = timestamps.ForcePosition, + }, + }; + return old with { Physics = physics }; } /// @@ -675,31 +1125,6 @@ public sealed class InboundPhysicsStateController TeleportHookRequired: false, previousTeleport); - private static WorldSession.EntitySpawn MirrorGateTimestamps( - WorldSession.EntitySpawn spawn, - PhysicsTimestampGate gate) - { - if (spawn.Physics is not { } desc) - return spawn; - - return spawn with - { - Physics = desc with - { - Timestamps = new PhysicsTimestamps( - gate.PositionTimestamp, - gate.MovementTimestamp, - gate.StateTimestamp, - gate.VectorTimestamp, - gate.TeleportTimestamp, - gate.ServerControlledMoveTimestamp, - gate.ForcePositionTimestamp, - gate.ObjDescTimestamp, - gate.InstanceTimestamp), - }, - }; - } - private static WorldSession.EntitySpawn MergeUntimestampedCreate( WorldSession.EntitySpawn retained, WorldSession.EntitySpawn incoming) => @@ -727,6 +1152,32 @@ public sealed class InboundPhysicsStateController Physics = retained.Physics, }; + /// + /// Instance seam for the SameIncarnationCreate envelope's + /// WeenieDescription stage (Round 3 A2). This must NOT be a wholesale + /// snapshot replacement of the raw retained packet - it merges exactly + /// like every other same-generation Create + /// (), keeping the retained + /// Position/appearance/physics-timestamp fields that earlier stages in + /// THIS envelope (and any earlier FIFO entry) already committed to + /// _snapshots, and taking only the incoming packet's untimestamped + /// identity/description fields. + /// + internal bool ApplyAcceptedWeenieDescriptionSnapshot( + uint guid, + WorldSession.EntitySpawn incoming, + out WorldSession.EntitySpawn merged) + { + if (!_snapshots.TryGetValue(guid, out WorldSession.EntitySpawn retained)) + { + merged = default; + return false; + } + merged = MergeUntimestampedCreate(retained, incoming); + _snapshots[guid] = merged; + return true; + } + private static SameGenerationCreateObjectEvents BuildSameGenerationEvents( WorldSession.EntitySpawn incoming) { diff --git a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs index 5405ecc7..1ec4a209 100644 --- a/src/AcDream.Runtime/Entities/ParentAttachmentState.cs +++ b/src/AcDream.Runtime/Entities/ParentAttachmentState.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -20,8 +21,53 @@ public sealed class ParentAttachmentState private readonly Dictionary> _committedChildrenByParent = new(); private readonly Dictionary> _deferredCreatesByParent = []; + /// + /// Round 5 R5-1: retail-faithful queue-by-parent-GUID deferral for an + /// ACCEPTED parent relation (standalone Parent continuation or envelope + /// CreateParent stage) whose parent is unaddressable or names a + /// not-yet-arrived incarnation. Shares the SAME per-guid "blobs waiting + /// on guid X" shape as - retail's + /// QueueBlobForObject/CObjectMaint bucket does not + /// distinguish a raw Create blob from any other blob type queued + /// against the same guid. + /// + private readonly Dictionary> + _deferredAcceptedRelationsByParent = []; private ulong _nextDeferredCreateAdmissionId; + /// + /// Round 5 R5-2: cancellation-aware detach/restore window state, shared + /// by BOTH deferred buckets. and + /// register one window + /// entry per detach; while it is open, every cancellation primitive + /// (, , + /// , , + /// ) ADDITIONALLY records its own retain + /// predicate into every currently-open window of the matching bucket + /// kind, so a later Restore call can apply the SAME filtering to the + /// detached remainder that would have applied had the batch never left + /// the live dictionary. wipes both window + /// dictionaries outright, which is what makes a stale token + /// (post-Clear/Dispose) restore nothing - the token's Id is simply + /// gone, an ABA-safe no-op via the same "TryRemove fails" pattern + /// already relies on. + /// + private sealed class CreateWindowState + { + internal required uint ParentGuid { get; init; } + internal List> Filters { get; } = []; + } + + private sealed class RelationWindowState + { + internal required uint ParentGuid { get; init; } + internal List> Filters { get; } = []; + } + + private readonly Dictionary _createWindows = []; + private readonly Dictionary _relationWindows = []; + private ulong _nextWindowId; + public int UnresolvedRelationCount => _unresolvedByChild.Values.Sum(queue => queue.Count); public int StagedRelationCount => _stagedByChild.Count; @@ -29,6 +75,8 @@ public sealed class ParentAttachmentState public int CommittedRelationCount => _lastAcceptedByChild.Count; internal int DeferredCreateCount => _deferredCreatesByParent.Values.Sum(queue => queue.Count); + internal int DeferredAcceptedRelationCount => + _deferredAcceptedRelationsByParent.Values.Sum(queue => queue.Count); /// /// Retains the complete unaccepted CreateObject packet when its nonzero @@ -102,6 +150,81 @@ public sealed class ParentAttachmentState return true; } + /// + /// Round 3 B7: retail's PartArray::add_child-owning CreateObject + /// handler detaches the ENTIRE queued netblob list for one parent + /// atomically before dispatching any of it (pseudo-C ~93617) - there is + /// no separate peek-then-remove step; the detach itself IS the consume. + /// Returns an empty array when nothing was queued. Structurally rules + /// out the stale-AdmissionId race the previous peek/consume replay loop + /// had to special-case: a Create arriving for this parent AFTER this + /// call enqueues into a brand-new queue instance, never the one already + /// removed here. Round 5 R5-2: opens a cancellation-aware window + /// () for the detached batch - see the window- + /// machinery remarks at this class's field declarations. + /// + internal ImmutableArray DetachDeferredCreates( + uint parentGuid, + out DeferredReplayWindowToken window) + { + if (!_deferredCreatesByParent.Remove( + parentGuid, + out Queue? queue)) + { + window = default; + return ImmutableArray.Empty; + } + ulong id = ++_nextWindowId; + _createWindows[id] = new CreateWindowState { ParentGuid = parentGuid }; + window = new DeferredReplayWindowToken(id, parentGuid, DeferredReplayBucketKind.Creates); + return [.. queue]; + } + + /// + /// Round 4 R4-1 / Round 5 R5-2: restores the unprocessed remainder of a + /// previously-detached replay batch, in original FIFO order and with + /// original values, at the + /// FRONT of the window's parent guid's queue - ahead of anything enqueued + /// for the same parent guid AFTER the detach. Every cancellation + /// primitive that fired WHILE this exact window was open recorded its + /// own retain predicate; those predicates are applied here before + /// re-insertion, so a child deleted (or otherwise cancelled) mid-replay + /// is never resurrected. ALWAYS call this once replay of the detached + /// batch concludes - successful or not - passing the empty remainder on + /// full success; this releases the window (a stale/already-released/ + /// Clear-invalidated token is an ABA-safe no-op, since its Id is simply + /// no longer tracked). + /// + internal void RestoreDeferredCreates( + in DeferredReplayWindowToken window, + ReadOnlySpan entries) + { + if (window.Kind != DeferredReplayBucketKind.Creates + || !_createWindows.Remove(window.Id, out CreateWindowState? state)) + { + return; + } + if (entries.Length == 0) + return; + IEnumerable filtered = entries.ToArray(); + foreach (Func filter in state.Filters) + filtered = filtered.Where(filter); + DeferredParentCreate[] survivors = filtered.ToArray(); + if (survivors.Length == 0) + return; + var restored = new Queue(survivors.Length); + foreach (DeferredParentCreate entry in survivors) + restored.Enqueue(entry); + if (_deferredCreatesByParent.TryGetValue( + window.ParentGuid, + out Queue? existing)) + { + foreach (DeferredParentCreate entry in existing) + restored.Enqueue(entry); + } + _deferredCreatesByParent[window.ParentGuid] = restored; + } + internal bool ContainsDeferredCreate( uint childGuid, ushort instanceSequence) @@ -119,18 +242,151 @@ public sealed class ParentAttachmentState return false; } + /// + /// Round 5 R5-1: retail-faithful replacement for the Round 4 discard - + /// a missing/not-yet-arrived parent QUEUES the accepted relation under + /// the PARENT's guid (standalone parent handler 0x004535D0 -> + /// QueueBlobForObject, pseudo-C 92326; GUID-keyed placeholder + /// bucket in CObjectMaint, 271082-271088) and replays it when + /// that guid is created, exactly like a raw missing-parent Create. + /// Shares the SAME monotonic AdmissionId source as + /// (never reset) - both buckets are + /// "blobs waiting on guid X," the same general retail mechanism. + /// + internal void EnqueueDeferredAcceptedRelation( + uint childGuid, + RuntimeEntityKey childKey, + ParentEvent.Parsed? standalone, + CreateParentUpdate? envelope, + AcceptedPhysicsTimestamps acceptedTimestamps) + { + uint parentGuid = standalone?.ParentGuid ?? envelope?.ParentGuid ?? 0u; + if (parentGuid == 0u || childGuid == 0u) + { + throw new ArgumentException( + "A deferred accepted parent relation requires nonzero parent and child GUIDs."); + } + if (_nextDeferredCreateAdmissionId == ulong.MaxValue) + { + throw new InvalidOperationException( + "The deferred parent CreateObject admission sequence is exhausted."); + } + ulong admissionId = _nextDeferredCreateAdmissionId + 1UL; + EnqueueDeferredAcceptedRelation(new DeferredAcceptedParentRelation( + admissionId, childGuid, childKey, standalone, envelope, acceptedTimestamps)); + _nextDeferredCreateAdmissionId = admissionId; + } + + /// + /// Re-enqueues an EXISTING relation verbatim, preserving its original + /// - used at + /// replay time when the relation still names a parent incarnation that + /// has not yet arrived (wait for the next matching incarnation). + /// + internal void EnqueueDeferredAcceptedRelation( + in DeferredAcceptedParentRelation relation) + { + uint parentGuid = relation.Standalone?.ParentGuid + ?? relation.Envelope?.ParentGuid + ?? 0u; + if (!_deferredAcceptedRelationsByParent.TryGetValue( + parentGuid, + out Queue? queue)) + { + queue = new Queue(); + _deferredAcceptedRelationsByParent.Add(parentGuid, queue); + } + queue.Enqueue(relation); + } + + /// Round 5 R5-2 window-aware detach - see 's remarks. + internal ImmutableArray DetachDeferredAcceptedRelations( + uint parentGuid, + out DeferredReplayWindowToken window) + { + if (!_deferredAcceptedRelationsByParent.Remove( + parentGuid, + out Queue? queue)) + { + window = default; + return ImmutableArray.Empty; + } + ulong id = ++_nextWindowId; + _relationWindows[id] = new RelationWindowState { ParentGuid = parentGuid }; + window = new DeferredReplayWindowToken(id, parentGuid, DeferredReplayBucketKind.AcceptedRelations); + return [.. queue]; + } + + /// Round 5 R5-2 window-aware restore - see 's remarks. + internal void RestoreDeferredAcceptedRelations( + in DeferredReplayWindowToken window, + ReadOnlySpan entries) + { + if (window.Kind != DeferredReplayBucketKind.AcceptedRelations + || !_relationWindows.Remove(window.Id, out RelationWindowState? state)) + { + return; + } + if (entries.Length == 0) + return; + IEnumerable filtered = entries.ToArray(); + foreach (Func filter in state.Filters) + filtered = filtered.Where(filter); + DeferredAcceptedParentRelation[] survivors = filtered.ToArray(); + if (survivors.Length == 0) + return; + var restored = new Queue(survivors.Length); + foreach (DeferredAcceptedParentRelation entry in survivors) + restored.Enqueue(entry); + if (_deferredAcceptedRelationsByParent.TryGetValue( + window.ParentGuid, + out Queue? existing)) + { + foreach (DeferredAcceptedParentRelation entry in existing) + restored.Enqueue(entry); + } + _deferredAcceptedRelationsByParent[window.ParentGuid] = restored; + } + + internal bool ContainsDeferredAcceptedRelation( + uint childGuid, + RuntimeEntityKey childKey) + { + foreach (Queue queue + in _deferredAcceptedRelationsByParent.Values) + { + if (queue.Any(candidate => + candidate.ChildGuid == childGuid + && candidate.ChildKey == childKey)) + { + return true; + } + } + return false; + } + /// /// Cancels only the raw, still-unaccepted child generation addressed by a /// terminal packet. Instance zero is a normal retail timestamp and is not - /// treated as an empty sentinel. + /// treated as an empty sentinel. Round 5 R5-1: also cancels a deferred + /// ACCEPTED relation for the same child incarnation - "child-addressed + /// candidates die with the child" applies identically to both buckets. /// internal void CancelDeferredChildGeneration( uint childGuid, - ushort terminalInstanceSequence) => FilterDeferredCreates( + ushort terminalInstanceSequence) + { + FilterDeferredCreates( candidate => candidate.Spawn.Guid != childGuid || PhysicsTimestampGate.IsNewer( terminalInstanceSequence, candidate.Spawn.InstanceSequence)); + FilterDeferredAcceptedRelations( + candidate => candidate.ChildGuid != childGuid + || PhysicsTimestampGate.IsNewer( + terminalInstanceSequence, + candidate.ChildKey.Incarnation)); + } public void AcceptCreateObjectRelation(ParentAttachmentRelation relation) { @@ -379,6 +635,7 @@ public sealed class ParentAttachmentState public void RemoveObject(uint guid) { RemoveDeferredChildCreates(guid); + RemoveDeferredAcceptedRelationsForChild(guid); _stagedByChild.Remove(guid); _recoveryByChild.Remove(guid); RemoveCommittedChild(guid); @@ -413,6 +670,12 @@ public sealed class ParentAttachmentState || PhysicsTimestampGate.IsNewer( replacementGeneration, candidate.Spawn.InstanceSequence)); + FilterDeferredAcceptedRelations(candidate => + candidate.ChildGuid != guid + || candidate.ChildKey.Incarnation == replacementGeneration + || PhysicsTimestampGate.IsNewer( + replacementGeneration, + candidate.ChildKey.Incarnation)); FilterChildCandidates( guid, relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent); @@ -468,6 +731,7 @@ public sealed class ParentAttachmentState public void RemoveChild(uint childGuid) { RemoveDeferredChildCreates(childGuid); + RemoveDeferredAcceptedRelationsForChild(childGuid); _stagedByChild.Remove(childGuid); _recoveryByChild.Remove(childGuid); RemoveCommittedChild(childGuid); @@ -477,6 +741,12 @@ public sealed class ParentAttachmentState public void Clear() { _deferredCreatesByParent.Clear(); + _deferredAcceptedRelationsByParent.Clear(); + // Round 5 R5-2: wipes every open window outright - a later + // Restore call for a token minted before this Clear() finds + // nothing to remove by Id and correctly no-ops (ABA-safe). + _createWindows.Clear(); + _relationWindows.Clear(); _unresolvedByChild.Clear(); _stagedByChild.Clear(); _recoveryByChild.Clear(); @@ -490,6 +760,15 @@ public sealed class ParentAttachmentState => FilterDeferredCreates( candidate => candidate.Spawn.Guid != childGuid); + private void RemoveDeferredAcceptedRelationsForChild(uint childGuid) + => FilterDeferredAcceptedRelations( + candidate => candidate.ChildGuid != childGuid); + + /// + /// Round 5 R5-2: filters the LIVE bucket exactly as before, then + /// records the SAME retain predicate into every currently-open create + /// window so a later Restore applies it to the detached remainder too. + /// private void FilterDeferredCreates( Func retain) { @@ -504,6 +783,27 @@ public sealed class ParentAttachmentState else _deferredCreatesByParent[parentGuid] = retained; } + foreach (CreateWindowState state in _createWindows.Values) + state.Filters.Add(retain); + } + + /// Round 5 R5-2 relation-bucket counterpart of . + private void FilterDeferredAcceptedRelations( + Func retain) + { + uint[] parents = _deferredAcceptedRelationsByParent.Keys.ToArray(); + for (int index = 0; index < parents.Length; index++) + { + uint parentGuid = parents[index]; + Queue retained = new( + _deferredAcceptedRelationsByParent[parentGuid].Where(retain)); + if (retained.Count == 0) + _deferredAcceptedRelationsByParent.Remove(parentGuid); + else + _deferredAcceptedRelationsByParent[parentGuid] = retained; + } + foreach (RelationWindowState state in _relationWindows.Values) + state.Filters.Add(retain); } private void RemoveCommittedChild(uint childGuid) @@ -609,6 +909,52 @@ internal readonly record struct DeferredParentCreate( && Spawn.Guid != 0u; } +/// +/// Round 5 R5-1: one ACCEPTED parent relation (the gate was already +/// consumed - the child's own POSITION_TS channel advanced at admission) +/// queued under its parent's guid because that parent was unaddressable or +/// named a not-yet-arrived incarnation. Carries exactly ONE of +/// (a standalone Parent continuation, which HAS a +/// parent incarnation to compare) or (an envelope +/// CreateParent stage, which does not). +/// +internal readonly record struct DeferredAcceptedParentRelation( + ulong AdmissionId, + uint ChildGuid, + RuntimeEntityKey ChildKey, + ParentEvent.Parsed? Standalone, + CreateParentUpdate? Envelope, + AcceptedPhysicsTimestamps AcceptedTimestamps) +{ + internal bool IsValid => AdmissionId != 0UL + && ChildGuid != 0u + && ChildKey.LocalEntityId != 0u + && (Standalone.HasValue ^ Envelope.HasValue); + + /// Null for the envelope flavor - carries no parent INSTANCE_TS. + internal ushort? ParentInstanceSequence => Standalone?.ParentInstanceSequence; +} + +/// Round 5 R5-2: which deferred bucket a belongs to. +internal enum DeferredReplayBucketKind : byte +{ + Creates, + AcceptedRelations, +} + +/// +/// Round 5 R5-2: opaque handle for one open detach/restore window. See the +/// window-machinery remarks at 's field +/// declarations for the full cancellation-awareness contract. +/// +internal readonly record struct DeferredReplayWindowToken( + ulong Id, + uint ParentGuid, + DeferredReplayBucketKind Kind) +{ + internal bool IsValid => Id != 0UL; +} + public readonly record struct ParentAttachmentRelation( uint ParentGuid, uint ChildGuid, diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs index f037dbf5..a9ac7b05 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityDirectory.cs @@ -547,6 +547,101 @@ public sealed class RuntimeEntityDirectory public bool IsFreshTeleportStart(uint guid, ushort teleportSequence) => _inbound.IsFreshTeleportStart(guid, teleportSequence); + // Round 3 A1: gate-less instance seams for the initial-Create + // continuation executor. Each merges against _inbound's OWN + // _snapshots[guid] (never a caller-supplied base) and writes the result + // back, keeping this store and RuntimeEntityRecord.Snapshot in lockstep. + internal bool ApplyAcceptedObjDescSnapshot( + uint guid, + ObjDescEvent.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedObjDescSnapshot(guid, update, out accepted); + + internal bool ApplyAcceptedPickupSnapshot( + uint guid, + PickupEvent.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedPickupSnapshot(guid, update, out accepted); + + internal bool ApplyAcceptedCreateParentSnapshot( + uint guid, + CreateParentUpdate update, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedCreateParentSnapshot(guid, update, out accepted); + + internal bool ApplyAcceptedParentSnapshot( + uint guid, + ParentEvent.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedParentSnapshot(guid, update, out accepted); + + internal bool ApplyAcceptedMotionSnapshot( + uint guid, + ushort movementSequence, + ushort acceptedServerControlledMove, + WorldSession.EntityMotionUpdate update, + bool retainPayload, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedMotionSnapshot( + guid, + movementSequence, + acceptedServerControlledMove, + update, + retainPayload, + out accepted); + + internal bool ApplyAcceptedStateSnapshot( + uint guid, + SetState.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedStateSnapshot(guid, update, out accepted); + + internal bool ApplyAcceptedVectorSnapshot( + uint guid, + VectorUpdate.Parsed update, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedVectorSnapshot(guid, update, out accepted); + + internal bool ApplyAcceptedPositionSnapshot( + uint guid, + WorldSession.EntityPositionUpdate update, + PositionTimestampDisposition disposition, + AcceptedPhysicsTimestamps timestamps, + bool isLocalPlayer, + System.Numerics.Quaternion? forcePositionRotation, + System.Numerics.Vector3? currentLocalVelocity, + bool installPlacementFrame, + bool clearParent, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedPositionSnapshot( + guid, + update, + disposition, + timestamps, + isLocalPlayer, + forcePositionRotation, + currentLocalVelocity, + installPlacementFrame, + clearParent, + out accepted); + + internal bool ApplyAcceptedPositionExecutionRejectedSnapshot( + uint guid, + ushort acceptedPositionSequence, + AcceptedPhysicsTimestamps timestamps, + out WorldSession.EntitySpawn accepted) => + _inbound.ApplyAcceptedPositionExecutionRejectedSnapshot( + guid, + acceptedPositionSequence, + timestamps, + out accepted); + + internal bool ApplyAcceptedWeenieDescriptionSnapshot( + uint guid, + WorldSession.EntitySpawn incoming, + out WorldSession.EntitySpawn merged) => + _inbound.ApplyAcceptedWeenieDescriptionSnapshot(guid, incoming, out merged); + private bool IsKnown(RuntimeEntityRecord record) { if (IsCurrent(record)) diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index a238d27a..a98e8c4b 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -31,6 +31,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( int EquipmentOwnerCount, int PendingMoveCount, int InitialCreateResidenceLeaseCount, + int InitialCreateExecutorProgressCount, int StreamSubscriberCount, int PlacementStreamSubscriberCount, long StreamDispatchFailureCount, @@ -38,7 +39,12 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( int PendingDispatchCount, bool IsDispatching, bool IsSessionClearInProgress, - bool IsDisposed) + bool IsDisposed, + /// Round 5 R5-1: pending queue-by-parent-GUID accepted relations (see ). + int DeferredAcceptedRelationCount = 0, + /// Round 5 R5-3: mirrors StreamDispatchFailureCount/HasLastStreamDispatchFailure for the executor's contained-replay failure surface. Diagnostic only - like its stream precedent, NOT gated by . + long ReplayFailureCount = 0, + bool HasLastReplayFailure = false) { public bool IsConverged => IsDisposed @@ -48,6 +54,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && AcceptedSnapshotCount == 0 && UnresolvedParentRelationCount == 0 && DeferredParentCreateCount == 0 + && DeferredAcceptedRelationCount == 0 && StagedParentRelationCount == 0 && RecoveryParentRelationCount == 0 && CommittedParentRelationCount == 0 @@ -57,6 +64,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot( && EquipmentOwnerCount == 0 && PendingMoveCount == 0 && InitialCreateResidenceLeaseCount == 0 + && InitialCreateExecutorProgressCount == 0 && StreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0 && PendingDispatchCount == 0 @@ -127,6 +135,23 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences = new RuntimeInitialCreateResidenceState( Entities, Physics.SetPosition); + InitialCreateExecution = new RuntimeInitialCreateContinuationExecutor( + Entities, + InitialCreateResidences, + Physics, + Events, + (spawn, isLocalPlayer) => + RegisterEntityWithInitialResidence(spawn, isLocalPlayer), + (canonical, version, spawn, replaceGeneration) => + ApplyAcceptedSpawn(canonical, version, spawn, replaceGeneration)); + // Round 3 B3: every residence retirement path - not only the + // executor's own DiscardProgress calls - must converge the + // executor's progress AND its separately-tracked pending + // continuation placement token. This class owns both sides of the + // relationship, so it binds the delegate here rather than the + // residence state referencing the executor type directly. + InitialCreateResidences.BindRetirementNotification( + key => InitialCreateExecution.DiscardProgress(key)); Placements = new RuntimePlacementProjectionChannel( Events, Physics.SetPosition); @@ -155,6 +180,23 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences = new RuntimeInitialCreateResidenceState( Entities, Physics.SetPosition); + InitialCreateExecution = new RuntimeInitialCreateContinuationExecutor( + Entities, + InitialCreateResidences, + Physics, + Events, + (spawn, isLocalPlayer) => + RegisterEntityWithInitialResidence(spawn, isLocalPlayer), + (canonical, version, spawn, replaceGeneration) => + ApplyAcceptedSpawn(canonical, version, spawn, replaceGeneration)); + // Round 3 B3: every residence retirement path - not only the + // executor's own DiscardProgress calls - must converge the + // executor's progress AND its separately-tracked pending + // continuation placement token. This class owns both sides of the + // relationship, so it binds the delegate here rather than the + // residence state referencing the executor type directly. + InitialCreateResidences.BindRetirementNotification( + key => InitialCreateExecution.DiscardProgress(key)); Placements = new RuntimePlacementProjectionChannel( Events, Physics.SetPosition); @@ -183,6 +225,23 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable InitialCreateResidences = new RuntimeInitialCreateResidenceState( Entities, Physics.SetPosition); + InitialCreateExecution = new RuntimeInitialCreateContinuationExecutor( + Entities, + InitialCreateResidences, + Physics, + Events, + (spawn, isLocalPlayer) => + RegisterEntityWithInitialResidence(spawn, isLocalPlayer), + (canonical, version, spawn, replaceGeneration) => + ApplyAcceptedSpawn(canonical, version, spawn, replaceGeneration)); + // Round 3 B3: every residence retirement path - not only the + // executor's own DiscardProgress calls - must converge the + // executor's progress AND its separately-tracked pending + // continuation placement token. This class owns both sides of the + // relationship, so it binds the delegate here rather than the + // residence state referencing the executor type directly. + InitialCreateResidences.BindRetirementNotification( + key => InitialCreateExecution.DiscardProgress(key)); Placements = new RuntimePlacementProjectionChannel( Events, Physics.SetPosition); @@ -197,6 +256,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable public RuntimePlacementProjectionChannel Placements { get; } internal RuntimeInitialCreateResidenceState InitialCreateResidences { get; } + internal RuntimeInitialCreateContinuationExecutor InitialCreateExecution + { get; } public RuntimeEntityObjectOwnershipSnapshot CaptureOwnership() { @@ -220,6 +281,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Objects.PendingMoveCount, initialResidence.ActiveLeaseCount + initialResidence.PendingAdoptionCount, + InitialCreateExecution.ProgressCount, Events.SubscriberCount, Events.PlacementSubscriberCount, Events.DispatchFailureCount, @@ -227,7 +289,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Events.PendingDispatchCount, Events.IsDispatching, _sessionClearInProgress, - _disposed); + _disposed, + parents.DeferredAcceptedRelationCount, + InitialCreateExecution.ReplayFailureCount, + InitialCreateExecution.LastReplayFailure is not null); } public void BindEventContext( @@ -238,6 +303,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable Events.BindContext(generation, frameNumber); Placements.BindGeneration(generation); InitialCreateResidences.BindGeneration(generation); + InitialCreateExecution.BindGeneration(generation); } /// @@ -1515,6 +1581,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable _sessionClearInProgress = true; RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray(); InitialCreateResidences.Clear(); + InitialCreateExecution.DiscardAll(); Physics.CollisionReports.LeaveWorldBatch(active); Physics.ResetSessionPhysics(); Entities.BeginSessionClear(); @@ -2028,12 +2095,20 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable private RuntimePlacementCancellationReceipt ForgetInitialCreateResidence( RuntimeEntityRecord canonical) { - return InitialCreateResidences.Forget( + bool forgotten = InitialCreateResidences.Forget( canonical, out _, - out RuntimePlacementCancellationReceipt cancellation) - ? cancellation - : default; + out RuntimePlacementCancellationReceipt cancellation); + // Round 3 B3: InitialCreateResidences.Forget's own retirement + // notification already routes to InitialCreateExecution.DiscardProgress + // for a successful Forget. This explicit call is defensive-in-depth + // for the (record never held a residence) case where Forget returns + // false without ever reaching the notification - DiscardProgress is + // idempotent, so a redundant call after a successful Forget is a + // guaranteed no-op, never a double-discard. + if (canonical.Key is { } key) + InitialCreateExecution.DiscardProgress(key); + return forgotten ? cancellation : default; } private static RuntimePlacementCancellationReceipt PreferCancellation( diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs new file mode 100644 index 00000000..9905b538 --- /dev/null +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateContinuationExecutor.cs @@ -0,0 +1,2107 @@ +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 +{ + /// Initial tail + entire FIFO revision applied + residence consumed. + Completed, + /// Initial authored placement not yet acknowledged; retry later. + PendingPlacement, + /// + /// 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 + /// + /// 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), + /// TryGetPendingContinuationPlacement returns false (no + /// token was ever begun) even though the overall status is still + /// AwaitingContinuationPlacement; the caller's only correct action is to + /// retry Execute 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. + /// + AwaitingContinuationPlacement, + RejectedToken, + /// Residence retired/superseded - abandoned, ledgers converged. + RejectedAuthority, +} + +/// +/// 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 IsGrounded bit (PositionPack +/// bit 0x4, server-asserted contact at admission time), never from a live +/// body query or a caller-supplied fallback. +/// +internal readonly record struct RuntimeInitialCreateExecutionInputs( + bool UsePositionFromServer, + float PlayerDistance); + +internal enum RuntimeInitialCreateExecutedActionKind : byte +{ + InitialAdoption, + TeleportHookRequest, + DeferredChildReplay, + /// Round 5 R5-1: one queued accepted parent relation replayed in this parent's own initial tail. + ParentRelationReplay, + PreTailDescriptionAdaptation, + ObjDesc, + CreateParent, + Parent, + Pickup, + Position, + Movement, + State, + Vector, + WeenieDescription, + ResidentCellCleanup, +} + +/// +/// Round 4 R4-6: outcome of one deferred child's replay registration +/// (). +/// Replaces the previous bool DeferredChildRegistered field, which +/// collapsed a genuine re-defer (the grandparent is ALSO still missing) +/// into the same "true" value as an outright successful registration. +/// +internal enum RuntimeDeferredChildReplayOutcome : byte +{ + /// was non-null. + Registered, + /// was true - the replayed child itself still has a missing (grand)parent. + ReDeferred, + /// Neither Canonical nor DeferredForParent - registration was rejected outright, or the registration callback threw (Round 4 R4-1). + Rejected, +} + +/// +/// 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). +/// +internal enum RuntimeParentRelationOutcome : byte +{ + /// The parent was addressable and current (or, at replay, named the exact live incarnation); the attach commit ran. + Applied, + /// + /// Round 5 R5-1: the LIVE parent incarnation is newer than the one this + /// relation named - retail-faithful discard, mirroring + /// '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. + /// + DiscardedStaleParent, + /// + /// 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 QueueBlobForObject + /// (pseudo-C 92326), replayed when that guid is created. + /// + DeferredAwaitingParent, + /// + /// 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. + /// + Rejected, +} + +/// +/// Retail's exact three-way ResidentCellCleanup disposition (retail-notes.md +/// function 1, SmartBox::HandleCreateObject 0x00454c80, lines ~788-801). +/// +internal enum RuntimeResidentCellCleanupDisposition : byte +{ + /// + /// objcell_id != 0 && cell != 0: already resident - + /// un-mark (RemoveObjectToBeDestroyed). + /// + ResidentUnmarked, + /// + /// objcell_id != 0 && cell == 0 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. + /// + DeferredUnderLostCellOwnership, + /// + /// No cell claimed at all (objcell_id == 0). Retail's own third + /// case (HandleCreateObject, 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 + /// SameIncarnationCreate continuation this codebase constructs + /// carries a WeenieDescription action immediately before + /// ResidentCellCleanup, never optionally + /// (, + /// RuntimeInitialCreateResidenceState.cs:277-284, enforces + /// Actions[^2].Kind is WeenieDescription 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. + /// + CelllessNoWeenieMarkUnreachable, +} + +/// +/// One immutable trace entry. is the owning +/// continuation's FIFO sequence (0 for initial-tail-only facts that precede +/// the FIFO entirely). is the same-incarnation envelope +/// action index, or -1 outside an envelope. +/// and are only meaningful for Position/hook-request +/// entries; only for +/// . +/// +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); + +/// +/// Host-independent immutable execution result. Hosts/tests consume this; the +/// executor never calls presentation. +/// +internal readonly record struct RuntimeInitialCreateExecutionReceipt( + RuntimeEntityKey Entity, + uint FullCellId, + RuntimeTeleportHookPhase TeleportHookPhase, + ImmutableArray Trace, + int ReplayedDeferredChildCount); + +/// +/// 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. +/// Execute is synchronous and retry-idempotent: a caller re-invokes it +/// after or +/// +/// once the placement token in the returned trace has been prepared, +/// submitted, and acknowledged by whatever drives +/// (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. +/// +internal sealed class RuntimeInitialCreateContinuationExecutor +{ + private enum InitialTailPhase : byte + { + NotStarted, + Adopted, + HookRecorded, + DeferredReplayed, + /// Round 5 R5-1: the accepted-relation queue for this guid has been drained. + RelationsReplayed, + } + + private readonly record struct PendingPublish( + RuntimeEntityChange Change, + Func 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; } + /// + /// Round 3 B1: true once a Position action's merge+publish has + /// committed but TryBeginExclusiveAuthoredPlacement 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 ApplyPositionAction + /// 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. + /// + internal bool PositionMergeCommittedForRetry { get; set; } + internal ulong PositionMergeCommittedVersion { get; set; } + internal int ReplayedDeferredChildCount { get; set; } + internal List EnvelopeBuffer { get; } = []; + internal ImmutableArray.Builder Trace { get; } = + ImmutableArray.CreateBuilder(); + } + + private readonly RuntimeEntityDirectory _entities; + private readonly RuntimeInitialCreateResidenceState _residences; + private readonly RuntimePhysicsState _physics; + private readonly RuntimeEntityObjectEventStream _events; + private readonly Func + _registerDeferredChild; + /// + /// Round 3 B12: mirrors + /// for the + /// residence path's WeenieDescription tail action. The executor holds no + /// direct reference to RuntimeEntityObjectLifetime (it is + /// constructed BY that owner) or its ClientObjectTable, so the + /// lifetime binds this delegate at construction the same way it binds + /// . + /// + private readonly Func + _applyAcceptedSpawn; + private readonly Dictionary _progress = []; + private readonly HashSet _executing = []; + private Func? _generation; + + internal RuntimeInitialCreateContinuationExecutor( + RuntimeEntityDirectory entities, + RuntimeInitialCreateResidenceState residences, + RuntimePhysicsState physics, + RuntimeEntityObjectEventStream events, + Func + registerDeferredChild, + Func + 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 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; + } + + internal int ProgressCount => _progress.Count; + + /// + /// Round 5 R5-3: mirrors the + /// 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. + /// + internal long ReplayFailureCount { get; private set; } + internal Exception? LastReplayFailure { get; private set; } + + private void RecordReplayFailure(Exception error) + { + ReplayFailureCount++; + LastReplayFailure = error; + } + + /// + /// Exposes the exact placement token a + /// + /// yield is waiting on, so a caller (a test harness today; a host at + /// cutover) can drive 's ordinary + /// prepare/submit/acknowledge cycle on it, exactly like it already does + /// for the initial lease's own placement token. + /// + 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; + } + + /// + /// Exposes the exact classified route the pending continuation placement + /// is running, so a caller can drive + /// with the matching + /// / + /// - the same information + /// already exposes for the initial placement. + /// + 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; + } + + /// + /// Deterministic cleanup hook wired into the SAME choke points that + /// forget a residence lease (). + /// 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 + /// ForgetInitialCreateResidence already forgets separately, and + /// distinct from the unconditional Physics.SetPosition.Forget + /// every existing ForgetInitialCreateResidence 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. + /// + internal void DiscardProgress(RuntimeEntityKey key) + { + if (!_progress.Remove(key, out Progress? progress)) + return; + if (progress.PendingContinuationPlacement.IsValid) + { + RuntimePlacementCancellationReceipt cancellation = + _physics.SetPosition.ForgetExactPlacement( + progress.PendingContinuationPlacement); + _physics.SetPosition.PublishCancellation(cancellation); + } + } + + /// + /// Deterministic bulk cleanup wired into + /// 's call site + /// (). Also + /// forgets every in-flight continuation placement token, defensively - + /// Physics.ResetSessionPhysics() runs immediately after this in + /// the same session-clear sequence and would otherwise be the only + /// thing to reap them. + /// + 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(); + } + + 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; + + // 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, inputs, 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: + receipt = new RuntimeInitialCreateExecutionReceipt( + key, + residenceReceipt.FullCellId, + residenceReceipt.TeleportHookPhase, + progress.Trace.ToImmutable(), + progress.ReplayedDeferredChildCount); + _progress.Remove(key); + 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); + } + } + } + + /// + /// 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. + /// 's own + /// retirement notification (bound at + /// construction) already + /// routes back to 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). + /// + 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; + } + + /// + /// 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 Execute or strands + /// the remaining siblings - each registration runs inside a try/catch, + /// recording a + /// 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 + /// - in original FIFO order, with + /// original AdmissionIds - rather than permanently destroying it. + /// Retail's own queued blobs live on CObjectMaint (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. + /// + private bool ReplayDeferredChildren(RuntimeEntityRecord canonical, Progress progress) + { + if (!_entities.IsCurrent(canonical)) + return false; + + ImmutableArray 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.Empty); + return true; + } + + /// + /// 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 + /// '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. + /// + private bool ReplayDeferredAcceptedRelations(RuntimeEntityRecord canonical, Progress progress) + { + if (!_entities.IsCurrent(canonical)) + return false; + + ImmutableArray 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.Empty); + return true; + } + + /// + /// Round 5 R5-1: incarnation dispatch vs THIS parent (), + /// mirroring '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. + /// + 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}."); + } + } + + /// + /// 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 -> QueueBlobForObject, pseudo-C 92326; GUID-keyed + /// placeholder bucket in CObjectMaint, 271082-271088) and replays + /// it via ProcessObjectNetBlobs 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 '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. + /// drains the queue this enqueues into, in the target parent's own + /// initial tail, after its raw-Create replay. + /// + 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; + } + + /// + /// 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. + /// is exactly retail's ApplyPositionTimestampOnly. None of the + /// four executor-tracked baseline fields move here (Round 4 R4-4), so + /// no AdvanceExecutorBaseline call belongs here either. + /// + 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; + } + + /// + /// The envelope's CreateParent stage revalidates parent ADDRESSABILITY + /// only - carries no + /// ParentInstanceSequence at all (retail-notes.md's + /// TryApplyCreateParent 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. + /// + private (bool Success, RuntimeParentRelationOutcome Outcome) ApplyCreateParentContinuation( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeEntityKey key, + RuntimeInitialCreateTailAction action, + List? 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); + } + + /// Instance-seam-only stamp - see 's remarks. + 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; + } + + /// + /// Round 3 B4: matches + /// '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?). + /// + 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? 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 + { + var authority = new RuntimeAuthoritativePositionAuthority( + CurrentGeneration(), + key, + canonical.PositionAuthorityVersion, + update.PositionSequence, + action.PreviousTeleportSequence, + action.AcceptedTimestamps.Teleport, + action.PositionDisposition); + + // Round 3 A3: contact comes SOLELY from the retained wire + // packet's own IsGrounded bit (PositionPack bit 0x4, + // server-asserted contact at admission time) - never a live + // body query, never an Inputs fallback. + bool hasContact = update.IsGrounded; + // Round 3 B5: HasAnimations is the SAME data-driven proxy for + // every position source, SameIncarnationCreate included - no + // PositionSource short-circuit. Round 4 R4-13: fall back to the + // nested PhysicsSpawnData's own MotionTableId when the + // top-level snapshot field is null (WeenieDescription/ObjDesc + // merges only ever populate one of the two, depending on + // which stage last touched appearance vs description). + bool hasAnimations = (canonical.Snapshot.MotionTableId + ?? canonical.Snapshot.Physics?.MotionTableId) is { } motionTableId + && motionTableId != 0u; + + var request = new RuntimeAcceptedPositionRouteRequest( + authority, + entityKind, + action.PositionSource, + update.Position, + update.PlacementId, + update.Velocity, + canonical.FullCellId, + hasContact, + inputs.PlayerDistance, + inputs.UsePositionFromServer, + hasAnimations, + new RuntimePositionPlacementFacts( + canonical.FinalPhysicsState, + canonical.Snapshot.SetupTableId is not null)); + + 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? 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; + } + + /// + /// 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 (ApplyAcceptedParentSnapshot/ + /// ApplyAcceptedCreateParentSnapshot, factored out into + /// / + /// ) 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 + /// here and broke + /// that test), the actual attach commit is out of scope for + /// this residence-continuation drain - it is the App-layer + /// EquippedChildRenderController's job, invoked through + /// only after + /// it validates the parent's render-side PartArray/holding-location can + /// actually host the child (see LiveEntityRuntime.CommitStagedParent'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 + /// '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). + /// is false ONLY at replay time, when the + /// child's own residence token is not held here - see + /// 's remarks for why that is + /// safe (fails closed, never silently blesses a baseline it does not + /// own). + /// + private void CommitParentAttachment( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + bool rebaseline, + List? 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? 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); + _physics.CollisionReports.LeaveWorld(canonical); + RuntimePlacementCancellationReceipt cancellation = + _physics.SetPosition.Forget(canonical); + _entities.SuspendObjectClock(canonical); + _entities.SetFullCell(canonical, 0u, 0u); + _entities.ParentAttachments.EndChildProjection(canonical.ServerGuid); + // 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? 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; + } + + /// + /// Round 4 R4-11: the BecameHidden branch's currency-failure path + /// returns false (routes the caller to the shared + /// Abandon/RejectedAuthority), not true 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 + /// RuntimeCollisionReportingState.LeaveWorld invoke an observer + /// callback for a residence-fresh entity - EndExpiredObjectCollisions + /// returns immediately whenever _owners has no established + /// collision record for this key (see + /// RuntimeCollisionReportingState.cs'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). + /// + private bool ApplyStateAction( + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeInitialCreateTailAction action, + List? 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? 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? 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; + } + + /// + /// Retail: SmartBox::HandleCreateObject's same-incarnation tail, final + /// step (retail-notes.md function 1, 0x00454c80, lines ~788-801) - + /// objcell_id != 0 && cell == 0 marks for destruction, + /// objcell_id != 0 && cell != 0 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. + /// + 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 matches, + RuntimePlacementCancellationReceipt cancellation, + List? 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 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; +} diff --git a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs index c8e1388c..636f1725 100644 --- a/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs +++ b/src/AcDream.Runtime/Entities/RuntimeInitialCreateResidenceState.cs @@ -362,6 +362,20 @@ internal enum RuntimeInitialCreateResidenceCompletionStatus : byte RejectedAuthority, } +/// +/// Result of , +/// the executor-only release that supersedes the host's +/// once +/// the initial placement has been adopted. +/// +internal enum RuntimeInitialCreateResidenceExecutorReleaseStatus : byte +{ + Released, + Revised, + RejectedToken, + RejectedAuthority, +} + /// /// Exact post-residence receipt. A local graphical or no-window host may run /// the retail after-enter teleport suffix only when this receipt carries @@ -385,6 +399,29 @@ internal readonly record struct RuntimeInitialCreateResidenceOwnershipSnapshot( && PendingAdoptionCount == 0; } +/// +/// Round 4 R4-4: field-masked precision for +/// . +/// The blanket four-field re-sync the executor previously called after +/// EVERY apply silently absorbed an external race on whichever field(s) a +/// given apply did NOT itself move - e.g. an ObjDesc/Movement/State/Vector +/// apply never touches PositionAuthorityVersion/CreateIntegrationVersion/ +/// FullCellId/PlacementCommitVersion, so blanket-resyncing all four there +/// would mask a genuine concurrent bump to one of them instead of letting +/// the next +/// check catch it. Each caller now passes exactly the field(s) its OWN +/// mutation moved. +/// +[Flags] +internal enum RuntimeExecutorBaselineFields : byte +{ + None = 0, + PositionAuthorityVersion = 1 << 0, + CreateIntegrationVersion = 1 << 1, + FullCellId = 1 << 2, + PlacementCommitVersion = 1 << 3, +} + /// /// Owns only initial CreateObject residence leases. DAT lookup, body creation, /// and presentation stay outside this owner; their immutable preparation is @@ -403,6 +440,48 @@ internal sealed class RuntimeInitialCreateResidenceState internal required RuntimeEntityRecord Record { get; init; } internal required RuntimeInitialCreateResidenceLease Lease { get; set; } internal required RuntimeInitialCreateResidenceReceipt Receipt { get; set; } + + /// + /// True once the continuation executor has consumed the initial + /// placement's acknowledged completion through + /// . A retained + /// _acknowledgedPlacementCompletions entry on + /// blocks EVERY later placement + /// begin for the same key (see + /// 's + /// HasRetainedCompletion guard) — a Position continuation could + /// never start its own authored placement while the initial one still + /// sits unconsumed. Adoption resolves that deadlock by consuming the + /// proof exactly once, while this flag keeps the completed entry + /// itself "current" for placement-tracking purposes even though the + /// placement token is no longer separately tracked. + /// + internal bool PlacementAdopted { get; set; } + + /// + /// Executor-tracked baseline for the four version/cell fields + /// compares against the LIVE record. + /// Seeded from 's own (frozen, identity-matching) + /// Token/FullCellId/PlacementCommitVersion at the + /// moment first produces this entry, then kept + /// in sync by every time the + /// continuation executor legitimately advances one of them while + /// applying a retained continuation. .Token + /// itself must NEVER be rebaselined — a caller (the executor) always + /// re-presents the SAME original token instance on every retry, and + /// 's own token-identity match + /// (completed.Receipt.Token == token) depends on that struct + /// staying byte-identical. Splitting "identity" (the frozen token) + /// from "expected current value" (these fields) is what lets the + /// executor's own sequential mutations keep the entry current + /// without the residence mistaking its own controlled progress for + /// an external race - see the 2026-08-01 admission handoff's own + /// warning about exactly this risk. + /// + internal ulong ExpectedPositionAuthorityVersion { get; set; } + internal ulong ExpectedCreateIntegrationVersion { get; set; } + internal uint ExpectedFullCellId { get; set; } + internal ulong ExpectedPlacementCommitVersion { get; set; } } private readonly RuntimeEntityDirectory _entities; @@ -410,6 +489,7 @@ internal sealed class RuntimeInitialCreateResidenceState private readonly Dictionary _entries = []; private readonly Dictionary _completed = []; private Func? _generation; + private Action? _retirementNotification; private ulong _nextLeaseId; internal RuntimeInitialCreateResidenceState( @@ -432,6 +512,31 @@ internal sealed class RuntimeInitialCreateResidenceState _generation = generation; } + /// + /// Round 3 B3: the ONE choke point every residence retirement path - + /// , , + /// , and - notifies through, + /// regardless of which caller (a host query, a staleness check inside + /// this class, or the continuation executor itself) triggered the + /// retirement. Without this, a residence retired by a path OTHER than + /// the executor's own DiscardProgress call (e.g. a host's + /// silently discovering staleness) would + /// leave the executor's progress AND its separately-tracked pending + /// continuation placement token orphaned - this class owns no reference + /// to the executor type, so the lifetime binds a plain delegate here + /// instead. + /// + internal void BindRetirementNotification(Action notify) + { + ArgumentNullException.ThrowIfNull(notify); + if (_retirementNotification is not null) + { + throw new InvalidOperationException( + "The initial Create residence retirement notification is already bound."); + } + _retirementNotification = notify; + } + internal bool CanAcceptCreate(WorldSession.EntitySpawn incoming) { bool parented = (incoming.ParentGuid @@ -788,10 +893,59 @@ internal sealed class RuntimeInitialCreateResidenceState Record = record, Lease = lease, Receipt = receipt, + ExpectedPositionAuthorityVersion = token.PositionAuthorityVersion, + ExpectedCreateIntegrationVersion = token.CreateIntegrationVersion, + ExpectedFullCellId = receipt.FullCellId, + ExpectedPlacementCommitVersion = receipt.PlacementCommitVersion, }); return RuntimeInitialCreateResidenceCompletionStatus.Completed; } + /// + /// Executor-only: re-synchronizes the completed entry's staleness + /// baseline (see + /// remarks) to the record's CURRENT live values, but ONLY for the + /// field(s) named in (Round 4 R4-4). Called + /// after the continuation executor legitimately advances one or more of + /// PositionAuthorityVersion/CreateIntegrationVersion/FullCellId/ + /// PlacementCommitVersion while applying a retained continuation, so a + /// LATER / check + /// does not mistake the executor's own controlled progress for an + /// external race. Passing a field NOT actually moved by the caller's own + /// mutation would defeat the whole point - it would silently bless an + /// external race on that field instead of letting the next currency + /// check catch it - so every call site names exactly its own field(s); + /// an apply that moves none of the four tracked fields (ObjDesc, + /// Movement, State, Vector) must not call this method at all. A no-op + /// (returns false) if the token no longer matches a live completed + /// entry - the executor's own currency checks catch that condition + /// independently and this call is purely advisory bookkeeping, never a + /// source of truth by itself. + /// + internal bool AdvanceExecutorBaseline( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken token, + RuntimeExecutorBaselineFields fields) + { + ArgumentNullException.ThrowIfNull(record); + if (!token.IsValid + || !_completed.TryGetValue(token.Entity, out CompletedEntry? entry) + || !ReferenceEquals(entry.Record, record) + || entry.Receipt.Token != token) + { + return false; + } + if ((fields & RuntimeExecutorBaselineFields.PositionAuthorityVersion) != 0) + entry.ExpectedPositionAuthorityVersion = record.PositionAuthorityVersion; + if ((fields & RuntimeExecutorBaselineFields.CreateIntegrationVersion) != 0) + entry.ExpectedCreateIntegrationVersion = record.CreateIntegrationVersion; + if ((fields & RuntimeExecutorBaselineFields.FullCellId) != 0) + entry.ExpectedFullCellId = record.FullCellId; + if ((fields & RuntimeExecutorBaselineFields.PlacementCommitVersion) != 0) + entry.ExpectedPlacementCommitVersion = record.PlacementCommitVersion; + return true; + } + internal bool AcknowledgeAdoption( RuntimeEntityRecord record, in RuntimeInitialCreateResidenceAdoptionToken token) @@ -816,7 +970,15 @@ internal sealed class RuntimeInitialCreateResidenceState // discard accepted packets. if (!current.Lease.Continuations.IsEmpty) return false; + // The executor's own release path is ConsumeExecuted, not this host + // method. If the executor already adopted the placement proof + // (RuntimeInitialCreateContinuationExecutor.AdoptCompletedPlacement), + // it is gone from RuntimeSetPositionState's tracking table entirely — + // do not re-consume it a second time, just tolerate the already- + // satisfied state and fall through to the same removal every other + // caller of this host method observes. if (current.Lease.Route.PerformsSetPosition + && !current.PlacementAdopted && !_setPosition.ConsumeAcknowledgedPlacement( current.Lease.Placement, current.Receipt.Projection)) @@ -841,6 +1003,7 @@ internal sealed class RuntimeInitialCreateResidenceState lease = entry.Lease; cancellation = _setPosition.ForgetExactPlacement( lease.Placement); + _retirementNotification?.Invoke(key); return true; } if (record.Key is { } completedKey @@ -853,6 +1016,7 @@ internal sealed class RuntimeInitialCreateResidenceState lease = completed.Lease; cancellation = _setPosition.ForgetExactPlacement( lease.Placement); + _retirementNotification?.Invoke(completedKey); return true; } lease = default; @@ -888,6 +1052,13 @@ internal sealed class RuntimeInitialCreateResidenceState { _setPosition.PublishCancellation(cancellations[index]); } + if (_retirementNotification is { } notify) + { + foreach (Entry entry in active) + notify(entry.Lease.Token.Entity); + foreach (CompletedEntry entry in completed) + notify(entry.Receipt.Token.Entity); + } } internal RuntimeInitialCreateResidenceOwnershipSnapshot CaptureOwnership() => @@ -917,6 +1088,24 @@ internal sealed class RuntimeInitialCreateResidenceState return _generation?.Invoke() ?? default; } + /// + /// The staleness check every completed-entry caller shares. Compares the + /// live record against + /// et al — an executor-tracked, continuously re-synchronized baseline — + /// rather than against 's + /// own FROZEN admission-time fields directly. This is what lets the + /// continuation executor's own legitimate mutations + /// (AdvancePositionAuthority, AdvanceCreateAuthority, SetFullCell, + /// AdvancePlacementCommit — all driven by applying a retained + /// continuation) keep this entry current across the many + /// re-entries a multi-call drain requires, while + /// still correctly detecting a genuine EXTERNAL race (anything that + /// changes one of these fields WITHOUT going through + /// ) exactly as it always did. The + /// token itself remains the untouched identity/match key - + /// 's completed.Receipt.Token == token check + /// depends on that. + /// private bool IsCompletedCurrent(CompletedEntry entry) { RuntimeInitialCreateResidenceReceipt receipt = entry.Receipt; @@ -925,35 +1114,147 @@ internal sealed class RuntimeInitialCreateResidenceState && _entities.SessionLifetimeVersion == receipt.Token.SessionLifetimeVersion && entry.Record.PositionAuthorityVersion - == receipt.Token.PositionAuthorityVersion + == entry.ExpectedPositionAuthorityVersion && entry.Record.CreateIntegrationVersion - == receipt.Token.CreateIntegrationVersion - && entry.Record.FullCellId == receipt.FullCellId + == entry.ExpectedCreateIntegrationVersion + && entry.Record.FullCellId == entry.ExpectedFullCellId && entry.Record.PlacementCommitVersion - == receipt.PlacementCommitVersion + == entry.ExpectedPlacementCommitVersion && entry.Lease.Route.Authority.Generation == CurrentGeneration() && receipt.Token.SessionLifetimeVersion == receipt.Adoption.SessionLifetimeVersion && receipt.Token.LeaseId == receipt.Adoption.LeaseId + // A completed entry whose placement proof the executor already + // adopted remains current on the placement dimension without + // re-querying RuntimeSetPositionState: AdoptCompletedPlacement + // consumed (removed) the exact tracked token, so + // IsPlacementCompletionTracked would now report false even though + // nothing here has gone stale. && (!entry.Lease.Route.PerformsSetPosition + || entry.PlacementAdopted || _setPosition.IsPlacementCompletionTracked( entry.Lease.Placement)); } + /// + /// Executor-only: consumes the initial placement's acknowledged + /// completion exactly once so a later retained Position continuation can + /// begin its own authored placement for the same + /// (see the remarks on + /// for why this is + /// necessary). Idempotent: a retry after is + /// already true is a no-op success, never a double-consume. + /// + internal bool AdoptCompletedPlacement( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceToken token) + { + ArgumentNullException.ThrowIfNull(record); + if (!token.IsValid + || !_completed.TryGetValue(token.Entity, out CompletedEntry? entry) + || !ReferenceEquals(entry.Record, record) + || entry.Receipt.Token != token) + { + return false; + } + if (entry.PlacementAdopted) + return IsCompletedCurrent(entry); + if (!IsCompletedCurrent(entry)) + { + Retire(entry); + return false; + } + if (!entry.Lease.Route.PerformsSetPosition) + { + // A Parented/PickedUp lease never captured a real placement + // token; there is nothing to consume, but the tail must still be + // able to progress past this step exactly once. + entry.PlacementAdopted = true; + return true; + } + if (!_setPosition.ConsumeAcknowledgedPlacement( + entry.Lease.Placement, + entry.Receipt.Projection)) + { + return false; + } + entry.PlacementAdopted = true; + return true; + } + + /// + /// Executor-only release: consumes the residence entirely once the exact + /// adoption token still matches AND the caller has applied every + /// continuation through the CURRENT lease's full length. Placement + /// consumption already happened via , + /// so this does not call + /// a second time for an adopted entry — unlike the host-facing + /// , which only ever runs for entries the + /// executor has not touched. + /// + internal RuntimeInitialCreateResidenceExecutorReleaseStatus ConsumeExecuted( + RuntimeEntityRecord record, + in RuntimeInitialCreateResidenceAdoptionToken token, + ulong executedThroughSequence) + { + ArgumentNullException.ThrowIfNull(record); + if (!token.IsValid + || !_completed.TryGetValue(token.Entity, out CompletedEntry? entry) + || !ReferenceEquals(entry.Record, record) + || entry.Receipt.Adoption.Entity != token.Entity + || entry.Receipt.Adoption.LeaseId != token.LeaseId) + { + return RuntimeInitialCreateResidenceExecutorReleaseStatus + .RejectedToken; + } + if (!IsCompletedCurrent(entry)) + { + Retire(entry); + return RuntimeInitialCreateResidenceExecutorReleaseStatus + .RejectedAuthority; + } + if (entry.Receipt.Adoption.Revision != token.Revision) + { + // A newer continuation arrived mid-drain (Enqueue bumps Revision + // in place on the SAME completed entry). The executor must + // re-fetch via Complete and drain the tail, never replay the + // already-applied prefix. + return RuntimeInitialCreateResidenceExecutorReleaseStatus.Revised; + } + if (!entry.PlacementAdopted && entry.Lease.Route.PerformsSetPosition) + { + throw new InvalidOperationException( + "Executor release requires the initial placement to have been adopted first."); + } + if ((ulong)entry.Lease.Continuations.Length != executedThroughSequence) + { + return RuntimeInitialCreateResidenceExecutorReleaseStatus + .RejectedAuthority; + } + return _completed.Remove(token.Entity) + ? RuntimeInitialCreateResidenceExecutorReleaseStatus.Released + : RuntimeInitialCreateResidenceExecutorReleaseStatus + .RejectedAuthority; + } + private void Retire(Entry entry) { - _entries.Remove(entry.Lease.Token.Entity); + RuntimeEntityKey key = entry.Lease.Token.Entity; + _entries.Remove(key); RuntimePlacementCancellationReceipt cancellation = _setPosition.ForgetExactPlacement(entry.Lease.Placement); _setPosition.PublishCancellation(cancellation); + _retirementNotification?.Invoke(key); } private void Retire(CompletedEntry entry) { - _completed.Remove(entry.Receipt.Token.Entity); + RuntimeEntityKey key = entry.Receipt.Token.Entity; + _completed.Remove(key); RuntimePlacementCancellationReceipt cancellation = _setPosition.ForgetExactPlacement(entry.Lease.Placement); _setPosition.PublishCancellation(cancellation); + _retirementNotification?.Invoke(key); } } diff --git a/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs b/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs index 0b0b0259..4edd2dbf 100644 --- a/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/InboundPhysicsStateControllerTests.cs @@ -339,6 +339,76 @@ public sealed class InboundPhysicsStateControllerTests Assert.Equal((ushort)2, retained.Physics!.Value.Timestamps.Movement); } + [Fact] + public void StaleMovementTimestampLeavesLegacySnapshotByteIdentical() + { + var controller = new InboundPhysicsStateController(); + WorldSession.EntitySpawn spawn = WithTimestamps( + Spawn(0x70000008u, 3, 1, 1, Position(0x0101FFFFu, 10f), 0x408u), + movement: 5, + serverControl: 5); + controller.AcceptCreate(spawn); + Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn before)); + + // MOVEMENT_TS 3 is OLDER than the stored 5: + // PhysicsTimestampGate.TryAcceptMovementEvent's + // AdvanceStrict(Movement, ...) fails before ever consulting + // SERVER_CONTROLLED_MOVE_TS - the gate is entirely untouched, and + // the canonical snapshot must not move either (the regression this + // guards: ApplyAcceptedMotion stamping the WIRE's own stale + // MovementSequence instead of the gate's unchanged post-call value). + bool applied = controller.TryApplyMotion( + new WorldSession.EntityMotionUpdate( + spawn.Guid, + new CreateObject.ServerMotionState(0x3d, 0x99), + InstanceSequence: 3, + MovementSequence: 3, + ServerControlSequence: 9, + IsAutonomous: false), + retainPayload: true, + out WorldSession.EntitySpawn accepted, + out _); + + Assert.False(applied); + Assert.Equal(default, accepted); + Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn after)); + Assert.Equal(before, after); + } + + [Fact] + public void InstanceMismatchedMovementLeavesLegacySnapshotByteIdentical() + { + var controller = new InboundPhysicsStateController(); + WorldSession.EntitySpawn spawn = WithTimestamps( + Spawn(0x70000009u, 3, 1, 1, Position(0x0101FFFFu, 10f), 0x408u), + movement: 1, + serverControl: 1); + controller.AcceptCreate(spawn); + Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn before)); + + // A different INSTANCE_TS: TryAcceptInstance fails immediately, + // before MOVEMENT_TS or SERVER_CONTROLLED_MOVE_TS are ever + // consulted. Nothing in the gate or the canonical snapshot may move + // - a stale duplicate or foreign-incarnation packet must not + // silently drag a live entity's movement timestamps forward. + bool applied = controller.TryApplyMotion( + new WorldSession.EntityMotionUpdate( + spawn.Guid, + new CreateObject.ServerMotionState(0x3d, 0x99), + InstanceSequence: 4, + MovementSequence: 9, + ServerControlSequence: 9, + IsAutonomous: false), + retainPayload: true, + out WorldSession.EntitySpawn accepted, + out _); + + Assert.False(applied); + Assert.Equal(default, accepted); + Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn after)); + Assert.Equal(before, after); + } + [Fact] public void FreshForceWithOlderTeleportMirrorsForceButRejectsPose() { diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs new file mode 100644 index 00000000..ab1b8a9b --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs @@ -0,0 +1,4464 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Entities; + +public sealed class RuntimeInitialCreateContinuationExecutorTests +{ + private const uint Landblock = 0xA9B50000u; + private const uint Cell = Landblock | 0x0001u; + private static readonly RuntimeInitialCreateExecutionInputs NoContact = + new(UsePositionFromServer: false, PlayerDistance: 0f); + + // --------------------------------------------------------------- + // A. Basic + // --------------------------------------------------------------- + + [Fact] + public void CompletedTopLevelCreateAdoptsOnceEmitsHookAndConvergesEveryLedger() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 1UL); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(0x70020001u, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal(Cell, receipt.FullCellId); + Assert.Equal(RuntimeTeleportHookPhase.AfterEnterWorld, receipt.TeleportHookPhase); + Assert.Equal(0, receipt.ReplayedDeferredChildCount); + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.TeleportHookRequest, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal( + 0, + lifetime.Physics.SetPosition.CaptureOwnership().AcknowledgedPlacementCompletionCount); + Assert.False(lifetime.TryGetInitialCreateResidence(canonical, out _)); + + // Retrying with the now-stale token is a distinct, safe no-op. + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _)); + } + + [Fact] + public void MixedSimpleContinuationsDrainInExactSequenceOrderAndMutateSnapshot() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 2UL); + const uint parentGuid = 0x70021100u; + const uint guid = 0x70021000u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.False(lease.Placement.IsValid); // parented -> AwaitFreshPosition, no placement + + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x04000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + var vector = new VectorUpdate.Parsed( + guid, new Vector3(1f, 2f, 3f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + var state = new SetState.Parsed( + guid, + (uint)PhysicsStateFlags.Gravity, + InstanceSequence: 1, + StateSequence: 2); + Assert.True(lifetime.TryApplyState(state, null, out _, out _)); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.ObjDesc, + RuntimeInitialCreateExecutedActionKind.Vector, + RuntimeInitialCreateExecutedActionKind.State, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal([1UL, 2UL, 3UL], + receipt.Trace + .Where(static a => a.Sequence != 0UL) + .Select(static a => a.Sequence)); + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Updated, RuntimeEntityChange.Updated], + observed); + Assert.Equal(0x04000002u, canonical.Snapshot.BasePaletteId); + Assert.Equal(new Vector3(1f, 2f, 3f), canonical.Snapshot.Physics!.Value.Velocity); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 4 R4-4 (mandated regression test): a FIFO of ObjDesc then Vector + // - NEITHER touches any of the four executor-tracked baseline fields + // (PositionAuthorityVersion/CreateIntegrationVersion/FullCellId/ + // PlacementCommitVersion), so NEITHER may call AdvanceExecutorBaseline + // at all. An observer bumps PositionAuthorityVersion externally during + // ObjDesc's own publish; because ObjDesc's apply never rebaselines that + // field, the STALE Expected value survives into the next check. The + // drain must detect this external race at ConsumeExecuted (the "next + // step") and abandon - NOT silently absorb it and report Released, which + // a blanket four-field rebaseline (the pre-R4-4 shape) would have done. + [Fact] + public void FieldMaskedBaselinePrecisionDetectsAnExternalPositionRaceDuringAnUnrelatedObjDescPublish() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 96UL); + const uint parentGuid = 0x70038000u; + const uint guid = 0x70038001u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x07000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + var vector = new VectorUpdate.Parsed( + guid, new Vector3(1f, 2f, 3f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + bool bumped = false; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (bumped || delta.Change is not RuntimeEntityChange.Updated) + return; + bumped = true; + // External, non-executor mutation of PositionAuthorityVersion + // during the ObjDesc stage's OWN publish. + lifetime.Entities.AdvancePositionAuthority(canonical); + })); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.True(bumped); + Assert.Equal(default, receipt); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 3 A1 (mandated regression test): the executor's applies must + // write InboundPhysicsStateController's OWN _snapshots[guid] - the + // legacy merge base - in lockstep with RuntimeEntityRecord.Snapshot. If + // they only wrote the record's own Snapshot (as before A1), the FIRST + // legacy wire apply reached after the residence drain would re-merge + // against a STALE _snapshots[guid] base and silently revert every fact + // the drain just committed. + [Fact] + public void DrainedAppearanceAndPoseSurviveTheNextLegacyWireApply() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 30UL); + const uint parentGuid = 0x7002D100u; + const uint guid = 0x7002D000u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + // Drain an ObjDesc continuation that changes appearance (a fresh + // BasePaletteId) as part of the residence FIFO. + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x06000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.ObjDesc, + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(0x06000002u, canonical.Snapshot.BasePaletteId); + + // Now run an ORDINARY legacy wire apply (Vector) on a channel the + // drain never touched. Its merge base is InboundPhysicsStateController's + // OWN _snapshots[guid] - if that store still held the pre-drain + // appearance (A1's bug), this apply's `old with { ... }` merge would + // carry the STALE BasePaletteId back into canonical.Snapshot via + // RefreshSnapshot, silently reverting the drained fact. + var vector = new VectorUpdate.Parsed( + guid, new Vector3(4f, 5f, 6f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + Assert.Equal(0x06000002u, canonical.Snapshot.BasePaletteId); + Assert.Equal(new Vector3(4f, 5f, 6f), canonical.Snapshot.Physics!.Value.Velocity); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 3 B12: the WeenieDescription tail action must drive the object + // table the same way RuntimeLiveEntitySessionController.OnSpawned does + // for the non-residence direct-host Create path (ApplyAcceptedSpawn) - + // the prior "zero callers" claim for this wiring was false. A + // residence-pending admission deliberately never wires the object table + // at the initial Create (RegisterEntityCore's beginInitialResidence + // branch); the FIRST time this guid's entry can appear is exactly here. + [Fact] + public void WeenieDescriptionStageWiresTheObjectTableExactlyOnce() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 34UL); + const uint guid = 0x7002E400u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + int objectCountBeforeDrain = lifetime.Objects.ObjectCount; + + WorldSession.EntitySpawn sameCreate = Spawn( + guid, 1, includePosition: false, positionSequence: 2); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { State = 2, Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.WeenieDescription, + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(objectCountBeforeDrain + 1, lifetime.Objects.ObjectCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 4 R4-3: ApplyWeenieDescriptionAction's object-table apply window. + // ClientObjectTable.Ingest publishes ObjectAdded/ObjectUpdated + // SYNCHRONOUSLY - a subscriber may re-enter a wire apply for the SAME + // entity from inside that dispatch. Because AdvanceExecutorBaseline now + // runs BEFORE _applyAcceptedSpawn (not after), the residence's baseline + // is already current at the moment the reentrant call runs; the + // residence is not retired and the envelope still completes. + [Fact] + public void ObjectTableSubscriberReenteringAWireApplyDuringIngestDoesNotRetireTheResidenceAndTheEnvelopeCompletes() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 94UL); + const uint guid = 0x70037000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + WorldSession.EntitySpawn sameCreate = Spawn( + guid, 1, includePosition: false, positionSequence: 2); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { State = 2, Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + bool reentered = false; + lifetime.Objects.ObjectAdded += addedObject => + { + if (reentered) + return; + reentered = true; + // A reentrant wire apply for a channel this envelope does NOT + // itself carry a dedicated stage for at this admission + // (Vector IS one of this envelope's own stages here, but the + // residence is still "pending" mid-drain, so this legitimately + // defers into the SAME FIFO rather than applying immediately - + // proving the residence survives the reentrant call). + var vector = new VectorUpdate.Parsed( + guid, new Vector3(9f, 8f, 7f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 3); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + }; + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.True(reentered); + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.WeenieDescription, + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 4 R4-3: a NESTED REPLACEMENT (a newer incarnation Create for the + // SAME guid) arriving from within Ingest's own synchronous dispatch + // invalidates the exact canonical incarnation the drain is still + // executing against. ApplyAcceptedSpawn re-checks currency AFTER its own + // object-table apply (mirroring RuntimeLiveEntitySessionController.cs:87's + // gate on that same call's result) and returns false; the executor must + // treat this as a typed abandonment - the remaining tail (ResidentCellCleanup) + // never runs. + [Fact] + public void NestedReplacementDuringObjectTableIngestAbandonsTheWeenieDescriptionStageWithNoFurtherStages() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 95UL); + const uint guid = 0x70037100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + WorldSession.EntitySpawn sameCreate = Spawn( + guid, 1, includePosition: false, positionSequence: 2); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { State = 2, Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + bool reentered = false; + lifetime.Objects.ObjectAdded += addedObject => + { + if (reentered) + return; + reentered = true; + _ = lifetime.RegisterEntity(Spawn(guid, 2, includePosition: false)); + }; + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.True(reentered); + Assert.Equal(default, receipt); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 3 A2 (mandated regression test): a standalone ObjDesc + // continuation admits a fresh palette (bumping the gate's ObjDesc + // channel and proving A1's snapshot lockstep feeds entry 2's merge + // base), THEN a same-incarnation Create arrives whose OWN raw + // WeenieDescription packet carries a DIFFERENT MotionTableId. Appearance + // fields (BasePaletteId etc.) are legitimately re-applied by the + // envelope's OWN dedicated ObjDesc stage (retail-faithful: a same- + // generation Create's tail always re-runs its own ObjDesc first) - that + // is NOT what A2 fixes. MotionTableId has NO dedicated envelope stage; + // WeenieDescription's merge is the ONLY place it can move, which + // isolates MergeUntimestampedCreate's "retained wins" rule cleanly: the + // prior wholesale RefreshSnapshot bug would have silently overwritten it + // with the incoming raw packet's value instead. + [Fact] + public void SameIncarnationWeenieDescriptionMergePreservesRetainedMotionTableId() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 31UL); + const uint parentGuid = 0x7002D300u; + const uint guid = 0x7002D200u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + + WorldSession.EntitySpawn initial = Spawn(guid, 1, includePosition: false, parentGuid: parentGuid); + PhysicsSpawnData initialPhysics = initial.Physics!.Value; + initial = initial with + { + MotionTableId = 0x12345678u, + Physics = initialPhysics with { MotionTableId = 0x12345678u }, + }; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(initial, isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + // FIFO entry 1: standalone ObjDesc with a NEW palette, bumping the + // gate's ObjDesc channel to 2. + var newAppearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x07000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(newAppearance, null, out _)); + + // FIFO entry 2: a same-incarnation Create whose OWN raw + // WeenieDescription packet carries a DIFFERENT MotionTableId and a + // NEWER-still ObjDesc channel stamp (3). + WorldSession.EntitySpawn sameCreate = Spawn( + guid, 1, includePosition: false, parentGuid: parentGuid, positionSequence: 2); + PhysicsSpawnData samePhysics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + MotionTableId = 0x99999999u, + Physics = samePhysics with + { + MotionTableId = 0x99999999u, + Timestamps = samePhysics.Timestamps with { ObjDesc = 3, State = 2, Vector = 2 }, + }, + }; + RuntimeEntityRegistrationResult same = lifetime + .RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, same.Inbound.Disposition); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.WeenieDescription, + receipt.Trace.Select(static a => a.Kind)); + // The envelope's OWN ObjDesc stage correctly re-applies THIS + // Create's own appearance (sameCreate never set its own palette, so + // it legitimately reverts to null - retail-faithful, not what A2 + // fixes). Entry 1's transient fresh palette was always going to be + // superseded by entry 2's OWN ObjDesc stage; that stage - not + // WeenieDescription - owns this field. + Assert.Null(canonical.Snapshot.BasePaletteId); + // MotionTableId has no dedicated envelope stage - WeenieDescription's + // merge must keep the RETAINED value, never adopt incoming's raw + // packet wholesale. + Assert.Equal(0x12345678u, canonical.Snapshot.Physics!.Value.MotionTableId); + Assert.Equal(0x12345678u, canonical.Snapshot.MotionTableId); + // ObjDesc's timestamp DOES have a dedicated stage that legitimately + // advances it further as part of entry 2's own admission (unlike + // MotionTableId). + Assert.Equal(3, canonical.Snapshot.Physics!.Value.Timestamps.ObjDesc); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // --------------------------------------------------------------- + // B. Standalone continuations (Movement/Pickup/Parent) + enqueue- + // during-drain (ConsumeExecuted's Revised arm). + // --------------------------------------------------------------- + + [Fact] + public void StandaloneMovementContinuationAppliesPayloadAndPublishesUpdated() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 40UL); + const uint guid = 0x7002F000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var motion = new WorldSession.EntityMotionUpdate( + guid, + new CreateObject.ServerMotionState(0x3d, 0x11), + InstanceSequence: 1, + MovementSequence: 2, + ServerControlSequence: 1, + IsAutonomous: false); + // Movement (2) advances and ServerControl (1) is equal-not-stale, so + // the gate accepts the payload outright. + Assert.True(lifetime.TryApplyMotion(motion, retainPayload: true, null, out _, out _)); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.Movement, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(new CreateObject.ServerMotionState(0x3d, 0x11), + canonical.Snapshot.MotionState); + Assert.Equal(2, canonical.Snapshot.Physics!.Value.Timestamps.Movement); + Assert.Equal([RuntimeEntityChange.Updated], observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void StandaloneMovementContinuationTimestampOnlyStampsWithoutPayloadOrPublish() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 41UL); + const uint guid = 0x7002F100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + Assert.Null(canonical.Snapshot.MotionState); + + var motion = new WorldSession.EntityMotionUpdate( + guid, + new CreateObject.ServerMotionState(0x3d, 0x11), + InstanceSequence: 1, + MovementSequence: 2, + // Retail consumes MOVEMENT_TS before discovering a stale + // SERVER_CONTROLLED_MOVE_TS (0, older than the gate's seeded 1) - + // Movement itself still advances (hasTimestampMutation) even + // though the overall event, and thus the payload, is rejected. + ServerControlSequence: 0, + IsAutonomous: false); + Assert.False(lifetime.TryApplyMotion(motion, retainPayload: true, null, out _, out _)); + Assert.True(lifetime.InitialCreateResidences.TryGetTransaction( + canonical, out RuntimeInitialCreateResidenceLease retained)); + Assert.Single(retained.Continuations); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.Movement, + receipt.Trace.Select(static a => a.Kind)); + // Timestamp landed; no payload, no publish (matches the legacy + // timestamp-only branch). + Assert.Equal(2, canonical.Snapshot.Physics!.Value.Timestamps.Movement); + Assert.Null(canonical.Snapshot.MotionState); + Assert.Empty(observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void StandalonePickupContinuationLeavesWorldThroughTheDrainAndResidenceStillReleases() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 42UL); + const uint guid = 0x7002F200u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + Assert.Equal(Cell, canonical.FullCellId); + + // A committed parent projection so Pickup's own EndChildProjection + // has something real to tear down. + var relation = new ParentAttachmentRelation( + 0x7002F300u, guid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 1, ChildPositionSequence: 1); + lifetime.Entities.ParentAttachments.AcceptCreateObjectRelation(relation); + Assert.True(lifetime.Entities.ParentAttachments.CommitProjection(relation)); + Assert.True(lifetime.Entities.ParentAttachments.HasCommittedParent(guid)); + + Assert.True(lifetime.TryApplyPickup( + new PickupEvent.Parsed(guid, InstanceSequence: 1, PositionSequence: 2), + null, + out _)); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + ulong clockEpochBefore = canonical.ObjectClockEpoch; + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.Pickup, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal([RuntimeEntityChange.Withdrawn], observed); + Assert.Equal(0u, canonical.FullCellId); + // SuspendObjectClock bumps the epoch - the clock is no longer live. + Assert.NotEqual(clockEpochBefore, canonical.ObjectClockEpoch); + Assert.False(lifetime.Entities.ParentAttachments.HasCommittedParent(guid)); + Assert.False(lifetime.Entities.ParentAttachments.TryGetRecoveryProjection(guid, out _)); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + // Residence still releases cleanly even though this continuation + // left the world mid-drain. + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void StandaloneParentContinuationAppliesPositionTimestampOnlyMergeWithNoReDefer() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 43UL); + const uint parentGuid = 0x7002F400u; + const uint childGuid = 0x7002F500u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var parentUpdate = new ParentEvent.Parsed( + parentGuid, + childGuid, + ParentLocation: 1u, + PlacementId: 0u, + ParentInstanceSequence: 1, + ChildPositionSequence: 2); + // The parent is alive at admission AND stays alive all the way + // through the drain - contrast with + // ParentContinuationRevalidatesLiveParentAtExecutionAndReDefersOnMismatch, + // which deletes the parent between admission and execution. + Assert.True(lifetime.TryApplyParent(parentUpdate, null, out _)); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.Parent, + ], + receipt.Trace.Select(static a => a.Kind)); + // ApplyAcceptedParent is position-timestamp-only: the shared + // POSITION_TS channel advances but no pose/ParentGuid field moves + // (the actual attach commit is TryCommitParent's job, out of scope + // here). + Assert.Equal(2, canonical.Snapshot.Physics!.Value.Timestamps.Position); + Assert.Null(canonical.Snapshot.ParentGuid); + Assert.Equal([RuntimeEntityChange.Updated], observed); + // The successful path hands NOTHING to ParentAttachments' unresolved + // bucket - the direct contrast with the re-defer test's + // UnresolvedRelationCount == 1. + Assert.Equal(0, lifetime.Entities.ParentAttachments.UnresolvedRelationCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 3 arc-10 (enqueue-during-drain): a continuation enqueued from an + // observer mid-drain bumps the completed entry's Adoption.Revision in + // place; ConsumeExecuted must return Revised (not Released) so the SAME + // Execute() call's outer while(true) loop re-fetches via Complete and + // drains only the newly-appended tail. The already-applied prefix must + // never replay. + [Fact] + public void EnqueueDuringDrainExercisesConsumeExecutedRevisedArmAndDrainsTailOnlyOnce() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 44UL); + const uint guid = 0x7002F600u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x08000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + + var observed = new List(); + bool enqueuedFromObserver = false; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + observed.Add(delta.Change); + if (enqueuedFromObserver) + return; + enqueuedFromObserver = true; + // Reentrant enqueue, mid-drain, from inside the ObjDesc + // continuation's own publish: the residence is still in + // RuntimeInitialCreateResidenceState's _completed dictionary + // (not yet ConsumeExecuted'd), so this routes through + // CanEnqueue's completed-entry branch and bumps Revision in + // place. + var vector = new VectorUpdate.Parsed( + guid, new Vector3(7f, 8f, 9f), Vector3.Zero, + InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + })); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + // Each action appears exactly once - the Revised loop-around must + // not replay the already-applied ObjDesc prefix. + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.ObjDesc, + RuntimeInitialCreateExecutedActionKind.Vector, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Updated], + observed); + Assert.Equal(0x08000002u, canonical.Snapshot.BasePaletteId); + Assert.Equal(new Vector3(7f, 8f, 9f), canonical.Snapshot.Physics!.Value.Velocity); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // --------------------------------------------------------------- + // C. Envelope atomicity + // --------------------------------------------------------------- + + [Fact] + public void SameIncarnationEnvelopePublishesNothingUntilEveryStageCommitsThenPublishesInStageOrder() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 3UL); + const uint guid = 0x70022000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + + WorldSession.EntitySpawn sameCreate = Spawn( + guid, 1, includePosition: false, positionSequence: 2) with + { + Name = "same-incarnation", + }; + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { ObjDesc = 2, State = 2, Vector = 2 }, + }, + }; + RuntimeEntityRegistrationResult same = lifetime + .RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, same.Inbound.Disposition); + + var observed = new List(); + int countAtFirstObservation = -1; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + observed.Add(delta.Change); + countAtFirstObservation = observed.Count; + })); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + // No event fired until the whole envelope committed: the very first + // observation must already carry every publish, not a partial subset + // trickling in one at a time. + Assert.True(observed.Count >= 1); + Assert.Equal(observed.Count, countAtFirstObservation); + + RuntimeInitialCreateExecutedActionKind[] envelopeStages = receipt.Trace + .Where(static a => a.Sequence == 1UL) + .Select(static a => a.Kind) + .ToArray(); + // A same-Create with neither a parent nor a position decomposes into + // a Pickup branch (retail priority: Parent > Position > Pickup - see + // InboundPhysicsStateController.BuildSameGenerationEvents), plus the + // AP-119 PreTailDescriptionAdaptation stage this envelope always + // carries when SameGenerationEvents is present. + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.PreTailDescriptionAdaptation, + RuntimeInitialCreateExecutedActionKind.ObjDesc, + RuntimeInitialCreateExecutedActionKind.Pickup, + RuntimeInitialCreateExecutedActionKind.State, + RuntimeInitialCreateExecutedActionKind.Vector, + RuntimeInitialCreateExecutedActionKind.WeenieDescription, + RuntimeInitialCreateExecutedActionKind.ResidentCellCleanup, + ], + envelopeStages); + // This entity never claimed a cell (PickedUp residence, then the + // envelope's own Pickup stage nulls the position again) - Gap 4(c), + // the no-cell-claimed destruction-mark disposition. + RuntimeInitialCreateExecutedAction cleanup = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ResidentCellCleanup); + Assert.Equal( + RuntimeResidentCellCleanupDisposition.CelllessNoWeenieMarkUnreachable, + cleanup.ResidentCellCleanupDisposition); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void ResidentCellCleanupUnmarksWhenCellClaimedAndAlreadyResident() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 18UL); + const uint guid = 0x7002A000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + Assert.Equal(Cell, canonical.FullCellId); + + // A same-Create carrying its own position: PositionSource == + // SameIncarnationCreate makes effectiveContact always true for the + // non-local classify branch, and the entity is already resident + // (FullCellId == Cell, unchanged since neither Position's own + // refreshPosition:false merge nor WeenieDescription's touch it), so + // this classifies to Interpolate - no new placement, no yield. + WorldSession.EntitySpawn sameCreate = Spawn(guid, 1, positionSequence: 2, positionX: 15f); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { ObjDesc = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 10f); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + RuntimeInitialCreateExecutedAction cleanup = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ResidentCellCleanup); + Assert.Equal( + RuntimeResidentCellCleanupDisposition.ResidentUnmarked, + cleanup.ResidentCellCleanupDisposition); + Assert.Equal(Cell, canonical.FullCellId); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void ResidentCellCleanupFailsClosedWhenClaimedCelllessOutcomeIsNotUnderLostCellOwnership() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 19UL); + const uint guid = 0x7002A100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.False(lease.Placement.IsValid); + Assert.Equal(0u, canonical.FullCellId); + + // The same-Create's own position exists on the wire (claims a + // cell), but with UsePositionFromServer=false this LocalPlayer + // classify branch resolves to NoPositionOperation - no SetPosition + // ever begins, so nothing tracks a lost-cell/deferred operation + // for this entity either. The entity was never resident (PickedUp + // initial, no placement ever ran) - claimed + celless + NOT under + // lost-cell ownership is the exact invariant violation this test + // requires. Round 3 B1: this is now a typed abandonment (Abandon + // retires the residence and discards progress), never a throw + // escaping Execute - the ledger must still fully converge. + WorldSession.EntitySpawn sameCreate = Spawn(guid, 1, positionSequence: 2, positionX: 15f); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { ObjDesc = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: true); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 0f); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, inputs, out RuntimeInitialCreateExecutionReceipt receipt); + Assert.Equal(RuntimeInitialCreateExecutionStatus.RejectedAuthority, status); + Assert.Equal(default, receipt); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.False(lifetime.TryGetInitialCreateResidence(canonical, out _)); + + // A retry with the now-retired token is a distinct, safe no-op - + // proving Abandon actually retired the residence rather than + // leaving it sitting fully current for a replay. + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, inputs, out RuntimeInitialCreateExecutionReceipt retryReceipt)); + Assert.Equal(default, retryReceipt); + } + + [Fact] + public void EnvelopePositionStageRequiringSetPositionYieldsResumesAndPublishesOnceAfterCompletion() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 20UL); + const uint guid = 0x7002B000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.False(lease.Placement.IsValid); + Assert.Equal(0u, canonical.FullCellId); + AttachDormantBody(lifetime, canonical); + + // Cellless (CommittedCellId == canonical.FullCellId == 0) forces + // SetPosition regardless of contact/distance for the non-local + // classify branch - this same-Create's OWN Position stage will + // require a real placement and yield mid-envelope. + WorldSession.EntitySpawn sameCreate = Spawn(guid, 1, positionSequence: 2, positionX: 15f); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { ObjDesc = 2, State = 2, Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt pending); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + Assert.Equal(default, pending); + // ObjDesc already committed (buffered) before Position began its + // own placement lifecycle - prove NOTHING has published yet, even + // though a stage before the yield point already ran. + Assert.Empty(observed); + + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPosition, route.Disposition); + CompletePendingContinuationPlacement(lifetime, key, route); + Assert.Empty(observed); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + // Every buffered per-stage event from this ONE envelope publishes + // exactly once, only now that the envelope fully committed. + Assert.Equal( + [ + RuntimeEntityChange.Updated, + RuntimeEntityChange.Updated, + RuntimeEntityChange.Updated, + RuntimeEntityChange.Updated, + RuntimeEntityChange.Updated, + ], + observed); + RuntimeInitialCreateExecutedActionKind[] envelopeStages = receipt.Trace + .Where(static a => a.Sequence == 1UL) + .Select(static a => a.Kind) + .ToArray(); + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.PreTailDescriptionAdaptation, + RuntimeInitialCreateExecutedActionKind.ObjDesc, + RuntimeInitialCreateExecutedActionKind.Position, + RuntimeInitialCreateExecutedActionKind.State, + RuntimeInitialCreateExecutedActionKind.Vector, + RuntimeInitialCreateExecutedActionKind.WeenieDescription, + RuntimeInitialCreateExecutedActionKind.ResidentCellCleanup, + ], + envelopeStages); + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPosition, + positionAction.PositionDisposition); + // The continuation's own SetPosition committed a real cell - + // ResidentCellCleanup now sees a claimed, resident entity. + Assert.Equal(Cell, canonical.FullCellId); + RuntimeInitialCreateExecutedAction cleanup = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ResidentCellCleanup); + Assert.Equal( + RuntimeResidentCellCleanupDisposition.ResidentUnmarked, + cleanup.ResidentCellCleanupDisposition); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + } + + // Gap 2 (failure injection between every same-Create envelope stage): + // the Position stage is the ONLY boundary inside a SameIncarnationCreate + // envelope that this suite can interrupt via a REAL yield. Every other + // stage kind - PreTailDescriptionAdaptation, ObjDesc, CreateParent/ + // Parent/Pickup, Movement, State, Vector, WeenieDescription, + // ResidentCellCleanup - is a pure, synchronous, in-memory + // RuntimeEntityRecord/InboundPhysicsStateController mutation with NO + // async placement lifecycle and NO event dispatched until the whole + // envelope's buffered publish flush at the very end (see ApplyEnvelope): + // there is no callback, no I/O, and no point where control ever returns + // to a caller (or where an event observer could reenter) mid-stage. + // Injecting a synthetic "failure" between two purely synchronous stages + // would require adding a diagnostic seam to production code purely to + // make a test possible, which this task's own instructions forbid. The + // Position stage is structurally different: it is the one action kind + // whose disposition can require a real RuntimeSetPositionState + // placement round-trip (Begin/Watch/yield/host-prepare-submit- + // acknowledge/resume) - the SAME mechanism a standalone (non-envelope) + // Position continuation uses. The two tests below exhaust that + // reachable boundary: a successful resume (see + // EnvelopePositionStageRequiringSetPositionYieldsResumesAndPublishesOnceAfterCompletion + // above) and an abandonment at that exact boundary (immediately below). + + [Fact] + public void EnvelopeAbandonedDuringPositionStageYieldPublishesNothingAndConvergesEveryLedger() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 21UL); + const uint guid = 0x7002B100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.False(lease.Placement.IsValid); + AttachDormantBody(lifetime, canonical); + + // Same recipe as the successful-resume test: cellless forces + // SetPosition, guaranteeing a mid-envelope yield after ObjDesc (and + // Position's own snapshot-merge commit) already ran. + WorldSession.EntitySpawn sameCreate = Spawn(guid, 1, positionSequence: 2, positionX: 15f); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { ObjDesc = 2, State = 2, Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + Assert.Empty(observed); + + // Abandon between Execute retries at the yield: delete the entity + // rather than ever completing the continuation's placement. + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(guid, canonical.Incarnation), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + + // The delete's own Deleted is the only observed event - NONE of the + // envelope's buffered stages (ObjDesc, and Position's own snapshot + // merge, both of which had ALREADY committed before the yield) ever + // publish. TryAcceptDelete's own unconditional Physics.SetPosition.Forget + // already cancels the still-pending continuation placement here + // (same key, any token); DiscardProgress ALSO forgets it + // defensively (see its remarks) for callers that retire a + // residence without going through the full delete path. + Assert.Equal([RuntimeEntityChange.Deleted], observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + Assert.Equal( + 0, + lifetime.Physics.SetPosition.CaptureOwnership().AcknowledgedPlacementCompletionCount); + + // A stale Execute() with the original token, after the entity is + // gone, must not resurrect anything either. + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + } + + [Fact] + public void EnvelopeStageRetryDoesNotDuplicateAlreadyCommittedStages() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 4UL); + const uint guid = 0x70022100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + + WorldSession.EntitySpawn sameCreate = Spawn( + guid, 1, includePosition: false, positionSequence: 2); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + // The first Execute call fully drains the envelope in one synchronous + // pass (no yield point exists in this envelope). A SECOND call with + // the same (now-stale, released) token must not resurrect or + // reapply anything. + RuntimeInitialCreateExecutionReceipt first = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + ulong vectorAuthorityAfterFirst = canonical.VectorAuthorityVersion; + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute(canonical, lease.Token, NoContact, out _)); + Assert.Equal(vectorAuthorityAfterFirst, canonical.VectorAuthorityVersion); + Assert.NotEmpty(first.Trace); + } + + // --------------------------------------------------------------- + // D. Position routes + // --------------------------------------------------------------- + + [Fact] + public void LocalOrdinaryPositionInterpolatesWithoutWorldPlacement() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 5UL); + const uint guid = 0x70023000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 15f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: true, PlayerDistance: 0f); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.Interpolate, + positionAction.PositionDisposition); + Assert.Equal(15f, canonical.Snapshot.Position!.Value.PositionX); + // Interpolate never runs a physics placement: the cell stays exactly + // what the initial SetPosition already committed. + Assert.Equal(Cell, canonical.FullCellId); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + // Round 4 R4-8: local ordinary route trace flags - CONSTRAIN-BEFORE + // (retail-notes.md function 3 line ~93041: ConstrainTo runs + // unconditionally BEFORE InterpolateTo on this route), unparent + // always runs on a received Position, and no teleport hook. + Assert.Equal(RuntimePositionConstrainPhase.BeforePositionOperation, positionAction.ConstrainPhase); + Assert.True(positionAction.UnparentBeforeRouting); + Assert.Equal(RuntimeTeleportHookPhase.None, positionAction.HookPhase); + } + + [Fact] + public void LocalTeleportContinuationDrivesItsOwnAuthoredPlacementLifecycle() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 6UL); + const uint guid = 0x70023100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, true, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt pending); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + Assert.Equal(default, pending); + // The snapshot's own position field is refreshed even before the + // physics placement resolves; the derived cell residency is not. + Assert.Equal(40f, canonical.Snapshot.Position!.Value.PositionX); + Assert.Equal(Cell, canonical.FullCellId); + + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple, route.Disposition); + CompletePendingContinuationPlacement(lifetime, key, route); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + positionAction.PositionDisposition); + // Round 4 R4-8: local teleport route trace flags - ZeroVelocity, + // CONSTRAIN-AFTER (retail: TeleportPlayer runs first, ConstrainTo + // second), the AfterPositionOperation teleport hook, and unparent + // (a received Position always unsets parent). + Assert.True(positionAction.ZeroVelocity); + Assert.Equal(RuntimePositionConstrainPhase.AfterPositionOperation, positionAction.ConstrainPhase); + Assert.Equal(RuntimeTeleportHookPhase.AfterPositionOperation, positionAction.HookPhase); + Assert.True(positionAction.UnparentBeforeRouting); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + } + + [Fact] + public void RemoteNearContactPositionInterpolatesAfterInitialResidency() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 7UL); + const uint guid = 0x70023200u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f, isGrounded: true); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 10f); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.Interpolate, + positionAction.PositionDisposition); + Assert.Equal(25f, canonical.Snapshot.Position!.Value.PositionX); + Assert.Equal(Cell, canonical.FullCellId); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + // Round 4 R4-8: remote near route trace flag - constrain-after + // (remote route: MoveOrTeleport runs first, ConstrainTo second). + Assert.Equal(RuntimePositionConstrainPhase.AfterPositionOperation, positionAction.ConstrainPhase); + } + + // Round 4 R4-7: wire IsGrounded=true disagrees with the body's OWN + // contact bit (forced false) - the local ordinary interpolate gate must + // follow the WIRE fact, not the body, per Round 3 A3. + [Fact] + public void LocalOrdinaryPositionRouteFollowsWireGroundedTrueWhenBodyContactIsFalse() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 97UL); + const uint guid = 0x70039000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + // Force the body's OWN contact bit to DISAGREE with the wire fact + // below - the route must ignore this. + ForceContact(canonical, inContact: false); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 15f, + isGrounded: true); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: true, PlayerDistance: 0f); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.Interpolate, + positionAction.PositionDisposition); + } + + // Round 4 R4-7: wire IsGrounded=false disagrees with the body's OWN + // contact bit (forced true) - the remote effective-contact gate must + // follow the WIRE fact, not the body. + [Fact] + public void RemotePositionRouteFollowsWireGroundedFalseWhenBodyContactIsTrue() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 98UL); + const uint guid = 0x70039100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + // Force the body's OWN contact bit to DISAGREE with the wire fact + // below - the route must ignore this. + ForceContact(canonical, inContact: true); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f, + isGrounded: false); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 10f); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.NoPositionOperation, + positionAction.PositionDisposition); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + } + + [Fact] + public void RemoteFarPositionStopsInterpolatingAndRunsSetPositionSimple() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 8UL); + const uint guid = 0x70023300u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 200f); + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, inputs, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple, route.Disposition); + Assert.True(route.StopInterpolating); + CompletePendingContinuationPlacement(lifetime, key, route); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + // Round 4 R4-8: remote far route trace flags - StopInterpolating + // and constrain-after. + Assert.True(positionAction.StopInterpolating); + Assert.Equal(RuntimePositionConstrainPhase.AfterPositionOperation, positionAction.ConstrainPhase); + } + + [Fact] + public void ParentedInitialCreateNeverRunsAWorldPlacementThroughTheExecutor() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 9UL); + const uint parentGuid = 0x70024100u; + const uint guid = 0x70024000u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + Assert.False(lease.Placement.IsValid); + Assert.Equal(RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, lease.Route.Disposition); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal(0u, receipt.FullCellId); + Assert.Equal(RuntimeTeleportHookPhase.None, receipt.TeleportHookPhase); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void ForcePositionContinuationRecordsSetPositionSimpleWithPreservedHeadingAndNoParentClear() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 45UL); + const uint guid = 0x7002F700u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + // Round 4 R4-12: give the entity a parent attachment BEFORE the + // ForcePosition update arrives, to structurally pin the merge + // body's deliberate combined shape - retail's FORCE_POSITION Gate A + // returns before CPhysicsObj::unset_parent ever runs, so the + // parent attachment must survive alongside the newly-applied + // Position after this drain. + const uint parentGuid = 0x7002F701u; + const uint parentLocation = 3u; + Assert.True(lifetime.Entities.TryCommitParent( + guid, parentGuid, parentLocation, placementId: 0u, positionSequence: 1, + out WorldSession.EntitySpawn parented)); + lifetime.Entities.RefreshSnapshot(canonical, parented); + + // isLocalPlayer + a fresh FORCE_POSITION_TS (1, newer than the + // seeded 0) + an EQUAL teleport sequence (0 == gate's current 0) + // blips immediately per PhysicsTimestampGate.TryAcceptPositionEvent. + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 1, positionX: 40f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple, route.Disposition); + CompletePendingContinuationPlacement(lifetime, key, route); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + positionAction.PositionDisposition); + Assert.True(positionAction.PreserveHeading); + Assert.True(positionAction.SendPositionImmediately); + Assert.False(positionAction.StopInterpolating); + Assert.False(positionAction.ZeroVelocity); + Assert.Equal(RuntimePositionConstrainPhase.None, positionAction.ConstrainPhase); + // The FORCE_POSITION branch precedes unset_parent in retail's + // MoveOrTeleport - no parent clearing here. + Assert.False(positionAction.UnparentBeforeRouting); + // Round 4 R4-12: structurally pin the deliberate combined shape - + // Position AND the pre-existing Parent attachment are BOTH + // non-null after this merge. + Assert.NotNull(canonical.Snapshot.Position); + Assert.Equal(40f, canonical.Snapshot.Position!.Value.PositionX); + Assert.Equal(parentGuid, canonical.Snapshot.ParentGuid); + Assert.Equal(parentLocation, canonical.Snapshot.ParentLocation); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + } + + [Fact] + public void MissileFlaggedEntityPositionContinuationClassifiesToProjectileAuthoritativeOperationKind() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 46UL); + const uint guid = 0x7002F800u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, missile: true), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + Assert.Equal( + RuntimeSetPositionOperationKind.ProjectileAuthoritative, + lease.Route.OperationKind); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + // Already resident + far distance forces SetPositionSimple (the + // same "remote-shaped" branch a Projectile entity kind shares with + // Remote - the classifier has no Projectile-specific branch, only a + // Projectile-specific OperationKind mapping). + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 200f); + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, inputs, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple, route.Disposition); + Assert.Equal(RuntimeSetPositionOperationKind.ProjectileAuthoritative, route.OperationKind); + Assert.True(route.StopInterpolating); + CompletePendingContinuationPlacement(lifetime, key, route); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + Assert.NotEmpty(receipt.Trace); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + // Round 4 R4-8: projectile route trace flags - same StopInterpolating + // + constrain-after shape as the remote-far branch (the classifier + // has no Projectile-specific branch, only an OperationKind mapping). + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.True(positionAction.StopInterpolating); + Assert.Equal(RuntimePositionConstrainPhase.AfterPositionOperation, positionAction.ConstrainPhase); + } + + // Contrasts with ParentedInitialCreateNeverRunsAWorldPlacementThroughTheExecutor: + // the PickedUp residence kind (no parent AND no position) is the OTHER + // branch that classifies to AwaitFreshPosition at Begin/Create time (see + // RuntimeInitialCreateResidenceState.Begin's residence-kind switch). + // NOTE (mandated-case coverage gap): a standalone Position CONTINUATION + // (as opposed to this initial-lease route) can never itself classify to + // AwaitFreshPosition - RuntimeAuthoritativePositionRouteClassifier. + // ClassifyAcceptedPosition (the only classifier ApplyPositionAction ever + // calls) has no branch that returns AwaitFreshPosition; that disposition + // is produced exclusively by ClassifyCreate (Parented/PickedUp) and + // ClassifyLeaveWorld (neither of which ApplyPositionAction calls). A + // parented/picked entity's raw Position wire events are retained as + // Position continuations exactly like any other entity's and are + // classified with the SAME Remote/LocalPlayer logic once drained - the + // "picked" or "parented" fact plays no role in that classification. This + // is a structural property of the current classifier, not a gap in this + // test suite; the reachable form of "AwaitFreshPosition for a + // parented/picked entity" is the INITIAL LEASE route exercised here and + // by ParentedInitialCreateNeverRunsAWorldPlacementThroughTheExecutor. + [Fact] + public void PickedUpInitialCreateNeverRunsAWorldPlacementThroughTheExecutor() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 47UL); + const uint guid = 0x7002F900u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + Assert.False(lease.Placement.IsValid); + Assert.Equal(RuntimeAuthoritativePositionDisposition.AwaitFreshPosition, lease.Route.Disposition); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal(0u, receipt.FullCellId); + Assert.Equal(RuntimeTeleportHookPhase.None, receipt.TeleportHookPhase); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 3 B10 (execution-time-rejected retained Position): admission + // ACCEPTED (disposition Apply - POSITION_TS genuinely advanced), but + // execution-time classification REJECTS on a nonfinite derived distance. + // The gate-consumed timestamps must still land in the snapshot; no pose + // ever applies. + [Fact] + public void ExecutionTimeRejectedPositionStampsRetainedTimestampsWithoutPoseApplication() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 48UL); + const uint guid = 0x7002FA00u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + Assert.Equal(Cell, canonical.FullCellId); + float originalPositionX = canonical.Snapshot.Position!.Value.PositionX; + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: float.NaN); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + RuntimeInitialCreateExecutedAction positionAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Position); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.RejectedData, + positionAction.PositionDisposition); + // The retained Position/Teleport/ForcePosition channels the gate + // actually moved land in the snapshot ... + Assert.Equal(2, canonical.Snapshot.Physics!.Value.Timestamps.Position); + Assert.Equal(0, canonical.Snapshot.Physics!.Value.Timestamps.Teleport); + Assert.Equal(0, canonical.Snapshot.Physics!.Value.Timestamps.ForcePosition); + // ... but no pose ever applies. + Assert.Equal(originalPositionX, canonical.Snapshot.Position!.Value.PositionX); + Assert.Equal(Cell, canonical.FullCellId); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // --------------------------------------------------------------- + // E. Missing-parent raw deferred-create replay + // --------------------------------------------------------------- + + [Fact] + public void MissingParentReplayConsumesExactAdmissionIdAndRegistersChildThroughCanonicalRoute() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 10UL); + const uint parentGuid = 0x70025100u; + const uint childGuid = 0x70025000u; + RuntimeEntityRegistrationResult deferred = lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false); + Assert.True(deferred.DeferredForParent); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, + out RuntimeInitialCreateResidenceLease parentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + Assert.Equal(1, receipt.ReplayedDeferredChildCount); + RuntimeInitialCreateExecutedAction replay = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.DeferredChildReplay); + Assert.Equal(RuntimeDeferredChildReplayOutcome.Registered, replay.DeferredChildOutcome); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord child)); + Assert.True(lifetime.TryGetInitialCreateResidence(child, out _)); + } + + [Fact] + public void StaleAdmissionIdCannotConsumeAReplacementQueuedAfterThePeek() + { + var parents = new ParentAttachmentState(); + const uint parentGuid = 0x70025200u; + WorldSession.EntitySpawn spawn = Spawn( + 0x70025300u, 1, includePosition: false, parentGuid: parentGuid); + parents.EnqueueDeferredCreate(spawn, isLocalPlayer: false); + Assert.True(parents.TryPeekDeferredCreate(parentGuid, out DeferredParentCreate stale)); + + parents.Clear(); + parents.EnqueueDeferredCreate(spawn, isLocalPlayer: false); + Assert.True(parents.TryPeekDeferredCreate(parentGuid, out DeferredParentCreate replacement)); + Assert.NotEqual(stale.AdmissionId, replacement.AdmissionId); + + Assert.False(parents.ConsumeDeferredCreate(parentGuid, stale)); + Assert.Equal(1, parents.DeferredCreateCount); + Assert.True(parents.ConsumeDeferredCreate(parentGuid, replacement)); + Assert.Equal(0, parents.DeferredCreateCount); + } + + // Round 3 B7: multiple children queued behind the SAME missing parent + // all replay atomically through the executor's whole-bucket detach + // (ParentAttachmentState.DetachDeferredCreates), in FIFO admission + // order, exercised through the canonical RunInitialTail path rather than + // against ParentAttachmentState directly. + [Fact] + public void MultipleChildrenQueuedBehindOneMissingParentReplayInFifoOrderThroughTheExecutor() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 32UL); + const uint parentGuid = 0x7002E100u; + const uint firstChildGuid = 0x7002E000u; + const uint secondChildGuid = 0x7002E001u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(2, lifetime.CaptureOwnership().DeferredParentCreateCount); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + Assert.Equal(2, receipt.ReplayedDeferredChildCount); + RuntimeInitialCreateExecutedAction[] replays = receipt.Trace + .Where(static a => a.Kind is RuntimeInitialCreateExecutedActionKind.DeferredChildReplay) + .ToArray(); + Assert.Equal(2, replays.Length); + Assert.All( + replays, + static a => Assert.Equal(RuntimeDeferredChildReplayOutcome.Registered, a.DeferredChildOutcome)); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.True(lifetime.Entities.TryGetActive(firstChildGuid, out _)); + Assert.True(lifetime.Entities.TryGetActive(secondChildGuid, out _)); + } + + // Round 4 R4-1: one child's registration THROWING must not strand the + // remaining siblings or escape Execute as an exception. Three children + // queued behind one missing parent; the SECOND child's own registration + // callback is made to throw (via a reflection-swapped + // _registerDeferredChild delegate - the standard fault-injection + // technique this file already uses for private-state pokes, e.g. + // SetCompletedAdoptionRevision). The third child must still register. + [Fact] + public void DeferredChildReplayContainsOneChildsThrowingRegistrationAndContinuesWithSiblings() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 92UL); + const uint parentGuid = 0x70035000u; + const uint firstChildGuid = 0x70035001u; + const uint secondChildGuid = 0x70035002u; + const uint thirdChildGuid = 0x70035003u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(thirdChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(3, lifetime.CaptureOwnership().DeferredParentCreateCount); + + WrapDeferredChildRegistration(lifetime, (original, spawn, isLocalPlayer) => + { + if (spawn.Guid == secondChildGuid) + { + throw new InvalidOperationException( + "Injected R4-1 containment test failure."); + } + return original(spawn, isLocalPlayer); + }); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + // No exception escapes Execute - it returns a typed status. + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + Assert.Equal(3, receipt.ReplayedDeferredChildCount); + RuntimeInitialCreateExecutedAction[] replays = receipt.Trace + .Where(static a => a.Kind is RuntimeInitialCreateExecutedActionKind.DeferredChildReplay) + .ToArray(); + Assert.Equal(3, replays.Length); + Assert.Equal(RuntimeDeferredChildReplayOutcome.Registered, replays[0].DeferredChildOutcome); + Assert.Equal(RuntimeDeferredChildReplayOutcome.Rejected, replays[1].DeferredChildOutcome); + Assert.Equal(RuntimeDeferredChildReplayOutcome.Registered, replays[2].DeferredChildOutcome); + Assert.True(lifetime.Entities.TryGetActive(firstChildGuid, out _)); + Assert.False(lifetime.Entities.TryGetActive(secondChildGuid, out _)); + Assert.True(lifetime.Entities.TryGetActive(thirdChildGuid, out _)); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + // Round 5 R5-3: the contained exception is recorded on the + // observable failure surface, not silently swallowed. + Assert.Equal(1, lifetime.CaptureOwnership().ReplayFailureCount); + Assert.True(lifetime.CaptureOwnership().HasLastReplayFailure); + } + + // Round 4 R4-1: a mid-loop abandonment (the parent entity is no longer + // current) must RESTORE the unprocessed remainder rather than + // permanently destroy it. Two children queued behind one missing + // parent; the FIRST child's registration callback reentrantly deletes + // the PARENT (simulating a synchronous observer reaction fired from + // within registration). The second child's raw Create must be back in + // the deferred bucket afterward (observable via ContainsDeferredCreate), + // and every ledger must still converge. + [Fact] + public void DeferredChildReplayRestoresTheUnprocessedRemainderWhenTheParentIsDeletedReentrantlyMidReplay() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 93UL); + const uint parentGuid = 0x70036000u; + const uint firstChildGuid = 0x70036001u; + const uint secondChildGuid = 0x70036002u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(2, lifetime.CaptureOwnership().DeferredParentCreateCount); + + WrapDeferredChildRegistration(lifetime, (original, spawn, isLocalPlayer) => + { + RuntimeEntityRegistrationResult result = original(spawn, isLocalPlayer); + if (spawn.Guid == firstChildGuid) + { + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + } + return result; + }); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + + // Child 1 legitimately registered before the reentrant delete + // fired; child 2's raw Create is restored to the deferred bucket + // rather than lost. + Assert.True(lifetime.Entities.TryGetActive(firstChildGuid, out RuntimeEntityRecord firstChild)); + Assert.True(lifetime.Entities.ParentAttachments.ContainsDeferredCreate(secondChildGuid, 1)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + // Only the PARENT's own residence/progress was touched by this + // abandoned Execute call (matching the existing + // DeleteDuringDeferredChildReplayAbandonsExecutionWithoutResurrection + // precedent) - child 1 is a distinct entity the deferred replay + // legitimately registered through the canonical route before the + // reentrant delete happened; its own (never executed) residence + // lease correctly remains open, accounting for the sole surviving + // lease count. + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.True(lifetime.TryGetInitialCreateResidence(firstChild, out _)); + } + + /// + /// Round 4 R4-1 test-only fault injection: swaps the executor's private + /// _registerDeferredChild delegate for one that wraps the + /// original, matching this file's existing reflection-based + /// private-state pokes (e.g. SetCompletedAdoptionRevision below). + /// + private static void WrapDeferredChildRegistration( + RuntimeEntityObjectLifetime lifetime, + Func< + Func, + WorldSession.EntitySpawn, + bool, + RuntimeEntityRegistrationResult> wrapper) + { + Type executorType = typeof(RuntimeInitialCreateContinuationExecutor); + System.Reflection.FieldInfo field = executorType.GetField( + "_registerDeferredChild", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + var original = (Func) + field.GetValue(lifetime.InitialCreateExecution)!; + Func wrapped = + (spawn, isLocalPlayer) => wrapper(original, spawn, isLocalPlayer); + field.SetValue(lifetime.InitialCreateExecution, wrapped); + } + + /// + /// Round 5 R5-1 test helper: both RuntimeEntityObjectLifetime. + /// TryApplyParent (line ~908) and RegisterEntityCore's own + /// missing-parent gate divert an UNADDRESSABLE-at-admission parent + /// relation to a DIFFERENT mechanism entirely (the legacy unresolved- + /// wait queue, or a raw deferred Create) - neither ever reaches the + /// residence continuation FIFO this round's executor dispatch code + /// operates on. The scenarios this round's tests exercise (drain-time/ + /// replay-time staleness) all require the parent to be ADDRESSABLE at + /// the moment of admission and only become stale/unaddressable + /// afterward - exactly the shape the original Round 3/4 test used. + /// This helper registers a real parent at + /// , admits the standalone + /// Parent continuation naming it, then removes the parent from the + /// ACTIVE directory directly (RuntimeEntityDirectory.RemoveActive) + /// rather than through the full DeleteObject-acceptance ceremony - a + /// real delete retains a permanent per-(guid,generation) teardown + /// tombstone (RuntimeEntityDirectory.RetainTeardown), which would + /// collide if a later test step re-registers the SAME guid at the SAME + /// incarnation (an invalid combination this scenario has no reason to + /// exercise - retail incarnations only ever increase). RemoveActive + /// makes the parent unaddressable for TryGetActive purposes + /// without that permanent marker, while the gate stays intact so a + /// LATER same-incarnation re-registration takes the codebase's own + /// existing "recovered CreateObject" ExistingGeneration path. + /// + private static void AdmitThenOrphanParentRelation( + RuntimeEntityObjectLifetime lifetime, + uint parentGuid, + uint childGuid, + ushort namedParentIncarnation, + uint parentLocation = 1u) + { + // includePosition:false - besides needing no physics engine, this + // also matters for the "same incarnation returns" scenario: a + // position-bearing retained snapshot would carry over through + // MergeUntimestampedCreate's Position=retained.Position copy when + // the SAME (guid, incarnation) is later re-registered on the + // "recovered CreateObject" ExistingGeneration path, reclassifying + // the recovered residence as TopLevel (SetPosition) instead of + // PickedUp (AwaitFreshPosition) and stalling RunToCompletion on an + // unrequested placement ack. + RuntimeEntityRecord parent = lifetime.RegisterEntity( + Spawn(parentGuid, namedParentIncarnation, includePosition: false)).Canonical!; + var parentUpdate = new ParentEvent.Parsed( + parentGuid, + childGuid, + ParentLocation: parentLocation, + PlacementId: 0u, + ParentInstanceSequence: namedParentIncarnation, + ChildPositionSequence: 2); + Assert.True(lifetime.TryApplyParent(parentUpdate, null, out _)); + Assert.True(lifetime.Entities.RemoveActive(parent)); + } + + // Round 3 B9 revalidated a standalone Parent continuation's parent + // incarnation at EXECUTION time, not just admission time - admission + // succeeds while the parent is still active; the parent is then + // deleted before the drain reaches this continuation. Round 4 R4-5 + // pinned the mismatch outcome as a DISCARD; Round 5 R5-1 OVERTURNS + // that with hard retail evidence (QueueBlobForObject, pseudo-C 92326; + // GUID-keyed CObjectMaint bucket, 271082-271088) - retail QUEUES a + // relation whose parent is unaddressable under the PARENT's guid and + // replays it when that guid is created; it never discards on this + // path. This test now covers R5-1's mandated "parent-returns -> queued + // relation applies exactly once" scenario end-to-end. + [Fact] + public void ParentContinuationRevalidatesLiveParentAtExecutionAndQueuesOnMismatchThenAppliesWhenTheParentArrives() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 33UL); + const uint parentGuid = 0x7002E200u; + const uint childGuid = 0x7002E300u; + _ = lifetime.RegisterEntity(Spawn(parentGuid, 1)); + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var parentUpdate = new ParentEvent.Parsed( + parentGuid, + childGuid, + ParentLocation: 1u, + PlacementId: 0u, + ParentInstanceSequence: 1, + ChildPositionSequence: 2); + // Admission succeeds - the parent is still active at this moment. + Assert.True(lifetime.TryApplyParent(parentUpdate, null, out _)); + + // The parent is deleted BEFORE the drain ever reaches this + // continuation - admission-time validation cannot see this. + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + // Queued (retail-faithful), not discarded: the residence still + // converges cleanly and the child's own canonical record survives. + RuntimeInitialCreateExecutedAction parentAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Parent); + Assert.Equal( + RuntimeParentRelationOutcome.DeferredAwaitingParent, + parentAction.ParentRelationOutcome); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out _)); + Assert.True(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + Assert.Null(canonical.Snapshot.ParentGuid); + } + + // Round 5 R5-1 mandated: "parent-returns -> queued relation applies + // exactly once" (trace + attach), end-to-end. Uses + // AdmitThenOrphanParentRelation (RemoveActive, not a full + // DeleteObject-acceptance) specifically so the SAME (guid, incarnation) + // can validly reappear afterward through the codebase's own existing + // "recovered CreateObject" ExistingGeneration path, without an + // artificial teardown-tombstone collision the scenario has no reason + // to exercise. + [Fact] + public void DeferredAcceptedParentRelationAppliesExactlyOnceWhenTheSameParentIncarnationReturns() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 113UL); + const uint parentGuid = 0x70049000u; + const uint childGuid = 0x70049001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AdmitThenOrphanParentRelation(lifetime, parentGuid, childGuid, namedParentIncarnation: 1); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + RuntimeInitialCreateExecutedAction parentAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.Parent); + Assert.Equal(RuntimeParentRelationOutcome.DeferredAwaitingParent, parentAction.ParentRelationOutcome); + Assert.True(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + + // The SAME parent incarnation (1) reappears - the queued relation + // replays and applies exactly once. + RuntimeEntityRecord recreatedParent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + recreatedParent, out RuntimeInitialCreateResidenceLease parentLease)); + RuntimeInitialCreateExecutionReceipt parentReceipt = RunToCompletion( + lifetime, recreatedParent, parentLease.Token, NoContact); + + RuntimeInitialCreateExecutedAction replayAction = Assert.Single( + parentReceipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ParentRelationReplay); + Assert.Equal(RuntimeParentRelationOutcome.Applied, replayAction.ParentRelationOutcome); + // Applied commits the SAME position-timestamp-only merge a live + // Parent continuation commits (already stamped at the relation's + // original drain, before it was queued) - it does not itself set + // ParentGuid/ParentLocation, matching + // StandaloneParentContinuationAppliesPositionTimestampOnlyMergeWithNoReDefer's + // established precedent that the actual attach commit is + // TryCommitParent's (App-layer EquippedChildRenderController's) job. + Assert.Equal(2, canonical.Snapshot.Physics!.Value.Timestamps.Position); + Assert.Null(canonical.Snapshot.ParentGuid); + Assert.False(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 5 R5-1 mandated: the arriving parent's incarnation is NEWER + // than the one the relation named - discard per Resolve's own rule + // (the current live parent supersedes the packet). + [Fact] + public void DeferredAcceptedParentRelationDiscardsWhenTheArrivingParentIsANewerIncarnationThanTheRelationNamed() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 104UL); + const uint parentGuid = 0x7003F000u; + const uint childGuid = 0x7003F001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + // Admits the standalone Parent continuation while the parent IS + // addressable (required - see AdmitThenOrphanParentRelation's + // remarks), then deletes the parent so the child's OWN drain + // discovers it stale and queues the relation. + AdmitThenOrphanParentRelation(lifetime, parentGuid, childGuid, namedParentIncarnation: 1); + _ = RunToCompletion(lifetime, canonical, lease.Token, NoContact); + Assert.True(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + + // Parent arrives directly at incarnation 2 - NEWER than the + // relation's named incarnation 1. + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 2, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + RuntimeInitialCreateExecutedAction replayAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ParentRelationReplay); + Assert.Equal(RuntimeParentRelationOutcome.DiscardedStaleParent, replayAction.ParentRelationOutcome); + Assert.False(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + Assert.Null(canonical.Snapshot.ParentGuid); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + } + + // Round 5 R5-1 mandated: the arriving parent's incarnation is OLDER + // than the one the relation named - stays queued (wait), then applies + // on the NEXT matching incarnation. This scenario is not constructible + // through the normal admission ceremony: naming ParentInstanceSequence + // 5 in a Parent continuation requires the LIVE parent to already BE at + // instance 5 at admission time (TryApplyParent's own gate), which + // pins the parent's OWN PhysicsTimestampGate at 5 - a client can never + // subsequently see that SAME guid at an OLDER instance 3 afterward + // (retail incarnations are per-guid monotonic; the gate enforces it). + // This test instead enqueues the relation directly through + // ParentAttachmentState's own public API (the same technique the ABA + // test below uses) to isolate ApplyReplayedParentRelation's OWN + // incarnation-compare logic from that inapplicable precondition. + [Fact] + public void DeferredAcceptedParentRelationStaysQueuedWhenTheArrivingParentIsOlderThanTheRelationNamedAndAppliesOnTheNextMatchingIncarnation() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 105UL); + const uint parentGuid = 0x70040000u; + const uint childGuid = 0x70040001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + _ = RunToCompletion(lifetime, canonical, lease.Token, NoContact); + var parentUpdate = new ParentEvent.Parsed( + parentGuid, childGuid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 5, ChildPositionSequence: 2); + lifetime.Entities.ParentAttachments.EnqueueDeferredAcceptedRelation( + childGuid, canonical.Key!.Value, parentUpdate, null, default); + + // Parent arrives at incarnation 3 - OLDER than the relation's + // named incarnation 5. + RuntimeEntityRecord parentAtThree = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 3, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parentAtThree, out RuntimeInitialCreateResidenceLease leaseThree)); + RuntimeInitialCreateExecutionReceipt receiptThree = RunToCompletion( + lifetime, parentAtThree, leaseThree.Token, NoContact); + RuntimeInitialCreateExecutedAction replayThree = Assert.Single( + receiptThree.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ParentRelationReplay); + Assert.Equal(RuntimeParentRelationOutcome.DeferredAwaitingParent, replayThree.ParentRelationOutcome); + Assert.True(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + + // Parent recreated at incarnation 5 - now matches. + RuntimeEntityRecord parentAtFive = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 5, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parentAtFive, out RuntimeInitialCreateResidenceLease leaseFive)); + RuntimeInitialCreateExecutionReceipt receiptFive = RunToCompletion( + lifetime, parentAtFive, leaseFive.Token, NoContact); + RuntimeInitialCreateExecutedAction replayFive = Assert.Single( + receiptFive.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ParentRelationReplay); + Assert.Equal(RuntimeParentRelationOutcome.Applied, replayFive.ParentRelationOutcome); + // Applied never sets ParentGuid itself - see + // StandaloneParentContinuationAppliesPositionTimestampOnlyMergeWithNoReDefer's + // established precedent (the actual attach commit is + // TryCommitParent's job, out of scope for this residence drain). + Assert.Null(canonical.Snapshot.ParentGuid); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + } + + // Round 5 R5-1 mandated: the child is deleted while its relation is + // still queued (parent never showed up) - the entry is cancelled and + // every ledger converges. + [Fact] + public void DeferredAcceptedParentRelationIsCancelledWhenTheChildIsDeletedWhileQueued() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 106UL); + const uint parentGuid = 0x70041000u; + const uint childGuid = 0x70041001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AdmitThenOrphanParentRelation(lifetime, parentGuid, childGuid, namedParentIncarnation: 1); + _ = RunToCompletion(lifetime, canonical, lease.Token, NoContact); + Assert.Equal(1, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(childGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + + Assert.False(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + } + + // Round 5 R5-1 mandated: a full session reset while a relation is + // queued clears it and converges. + [Fact] + public void DeferredAcceptedParentRelationClearsOnSessionResetAndConverges() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 107UL); + const uint parentGuid = 0x70042000u; + const uint childGuid = 0x70042001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AdmitThenOrphanParentRelation(lifetime, parentGuid, childGuid, namedParentIncarnation: 1); + _ = RunToCompletion(lifetime, canonical, lease.Token, NoContact); + Assert.Equal(1, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + + IReadOnlyList retirements = lifetime.BeginSessionClear(); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + foreach (RuntimeEntityRecord record in retirements) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + } + + // Round 5 R5-1 mandated ABA coverage, tested directly against + // ParentAttachmentState (mirroring + // StaleAdmissionIdCannotConsumeAReplacementQueuedAfterThePeek's own + // direct-state style): a window token minted BEFORE a Clear() must not + // restore anything afterward, even though a NEW window for the same + // parent guid could otherwise reuse its Id space. + [Fact] + public void DeferredAcceptedParentRelationStaleWindowTokenCannotRestoreAfterClearAndRequeue() + { + var parents = new ParentAttachmentState(); + const uint parentGuid = 0x70043000u; + const uint childGuid = 0x70043001u; + var childKey = new RuntimeEntityKey(1u, 1); + var parentUpdate = new ParentEvent.Parsed( + parentGuid, childGuid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 1, ChildPositionSequence: 2); + parents.EnqueueDeferredAcceptedRelation(childGuid, childKey, parentUpdate, null, default); + + ImmutableArray detached = + parents.DetachDeferredAcceptedRelations(parentGuid, out DeferredReplayWindowToken staleWindow); + DeferredAcceptedParentRelation stale = Assert.Single(detached); + + // Reentrant Clear() (session reset) while the window is still open. + parents.Clear(); + + // A restore against the now-stale token must be a silent no-op. + parents.RestoreDeferredAcceptedRelations(staleWindow, [stale]); + Assert.Equal(0, parents.DeferredAcceptedRelationCount); + Assert.False(parents.ContainsDeferredAcceptedRelation(childGuid, childKey)); + } + + // Round 5 R5-1 mandated: the envelope's CreateParent stage, end-to-end, + // for the unaddressable-parent flavor (addressability-only, no + // incarnation to compare). RegisterEntityCore's OWN missing-parent gate + // (checked before PreviewCreateDisposition, for ANY beginInitialResidence + // call) diverts a same-incarnation update naming an UNADDRESSABLE parent + // to a raw deferred Create entirely, bypassing the envelope/CreateParent + // stage - so, exactly like the standalone Parent continuation, this + // scenario requires the parent to be ADDRESSABLE at the moment of + // admission and only orphaned afterward. + [Fact] + public void EnvelopeCreateParentQueuesForUnaddressableParentAndAppliesWhenTheParentArrives() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 108UL); + const uint parentGuid = 0x70044000u; + const uint childGuid = 0x70044001u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + // includePosition:false, matching AdmitThenOrphanParentRelation's own + // remarks - avoids both the physics-engine dependency and a + // position-bearing retained snapshot bleeding into the LATER + // same-incarnation "recovered CreateObject" merge below. + RuntimeEntityRecord orphanedParent = lifetime.RegisterEntity( + Spawn(parentGuid, 1, includePosition: false)).Canonical!; + + WorldSession.EntitySpawn sameCreate = Spawn( + childGuid, 1, includePosition: false, positionSequence: 2, parentGuid: parentGuid); + PhysicsSpawnData physics = sameCreate.Physics!.Value; + sameCreate = sameCreate with + { + Physics = physics with + { + Timestamps = physics.Timestamps with { State = 2, Vector = 2 }, + }, + }; + _ = lifetime.RegisterEntityWithInitialResidence(sameCreate, isLocalPlayer: false); + + // The parent is now orphaned - removed from the active directory + // AFTER the envelope's CreateParent stage was admitted while it was + // still addressable. RemoveActive (not a full DeleteObject-acceptance) + // for the SAME reason AdmitThenOrphanParentRelation uses it: a real + // delete retains a permanent per-(guid,generation) teardown tombstone + // that would collide when the SAME incarnation is re-registered below. + Assert.True(lifetime.Entities.RemoveActive(orphanedParent)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + RuntimeInitialCreateExecutedAction createParentAction = Assert.Single( + receipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.CreateParent); + Assert.Equal(RuntimeParentRelationOutcome.DeferredAwaitingParent, createParentAction.ParentRelationOutcome); + Assert.True(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(childGuid, canonical.Key!.Value)); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + RuntimeInitialCreateExecutionReceipt parentReceipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + RuntimeInitialCreateExecutedAction replayAction = Assert.Single( + parentReceipt.Trace, + static a => a.Kind is RuntimeInitialCreateExecutedActionKind.ParentRelationReplay); + Assert.Equal(RuntimeParentRelationOutcome.Applied, replayAction.ParentRelationOutcome); + // Applied never sets ParentGuid itself - see + // StandaloneParentContinuationAppliesPositionTimestampOnlyMergeWithNoReDefer's + // established precedent (the actual attach commit is + // TryCommitParent's job, out of scope for this residence drain). + Assert.Null(canonical.Snapshot.ParentGuid); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + } + + // --------------------------------------------------------------- + // Round 5 R5-2: cancellation-aware detach/restore window, both + // deferred buckets. + // --------------------------------------------------------------- + + // Reviewer scenario (a) for the CREATES bucket: two children queued + // behind one missing parent; C1's own registration callback delivers + // DeleteObject(C2) then DeleteObject(parent) - C2 must NOT be restored, + // the recreated parent drains nothing, and every ledger converges. + [Fact] + public void DeferredChildReplayWindowFiltersASiblingDeletedMidReplayFromTheRestoredRemainder() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 109UL); + const uint parentGuid = 0x70045000u; + const uint firstChildGuid = 0x70045001u; + const uint secondChildGuid = 0x70045002u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + + RuntimeEntityRecord? firstChild = null; + WrapDeferredChildRegistration(lifetime, (original, spawn, isLocalPlayer) => + { + RuntimeEntityRegistrationResult result = original(spawn, isLocalPlayer); + if (spawn.Guid == firstChildGuid) + { + firstChild = result.Canonical; + // C2 was never individually registered (still a raw + // deferred blob) - its own delete legitimately reports + // false (TryAcceptDelete's known-object gate never + // accepted it), but CancelDeferredChildGeneration still + // runs unconditionally before that gate and records the + // window filter. + _ = lifetime.TryAcceptDelete( + new DeleteObject.Parsed(secondChildGuid, 1), isLocalPlayer: false, + removeRetainedObject: true, out _); + + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, 1), isLocalPlayer: false, + removeRetainedObject: true, out RuntimeEntityDeleteAcceptance parentAcceptance)); + lifetime.CompleteAcceptedDelete(parentAcceptance); + } + return result; + }); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.True(lifetime.Entities.TryGetActive(firstChildGuid, out _)); + Assert.False(lifetime.Entities.ParentAttachments.ContainsDeferredCreate(secondChildGuid, 1)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredCreateCount); + + // C1 was itself admitted as its own ("Parented") initial-residence + // registration - a REAL entity, not a raw blob - and that lease is + // independent of the parentGuid-keyed deferred-create window this + // test targets. Drain it too so every ledger genuinely converges + // (round5-fixes.md's own "ledgers converge" wording), rather than + // leaving an unrelated open lease that has nothing to do with the + // window/filter mechanism under test. + Assert.NotNull(firstChild); + Assert.True(lifetime.TryGetInitialCreateResidence( + firstChild!, out RuntimeInitialCreateResidenceLease firstChildLease)); + _ = RunToCompletion(lifetime, firstChild!, firstChildLease.Token, NoContact); + + RuntimeEntityRecord recreatedParent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 2, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + recreatedParent, out RuntimeInitialCreateResidenceLease recreatedLease)); + RuntimeInitialCreateExecutionReceipt recreatedReceipt = RunToCompletion( + lifetime, recreatedParent, recreatedLease.Token, NoContact); + Assert.Equal(0, recreatedReceipt.ReplayedDeferredChildCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + } + + // Reviewer scenario (b) for the CREATES bucket: C1's callback drives a + // full session clear - restore no-ops, DeferredParentCreateCount stays + // 0, IsConverged holds after teardown. + [Fact] + public void DeferredChildReplayWindowReleasesSilentlyWhenSessionClearFiresMidReplay() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 110UL); + const uint parentGuid = 0x70046000u; + const uint firstChildGuid = 0x70046001u; + const uint secondChildGuid = 0x70046002u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + + WrapDeferredChildRegistration(lifetime, (original, spawn, isLocalPlayer) => + { + RuntimeEntityRegistrationResult result = original(spawn, isLocalPlayer); + if (spawn.Guid == firstChildGuid) + { + IReadOnlyList retirements = lifetime.BeginSessionClear(); + foreach (RuntimeEntityRecord record in retirements) + lifetime.CompleteSessionEntityRetirement(record); + } + return result; + }); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + } + + // Reviewer scenario (a), relation-queue flavor: two queued relations + // for the same parent; C1's own attach-commit publish delivers + // DeleteObject(C2) then DeleteObject(parent) - C2's relation must NOT + // be restored. + [Fact] + public void DeferredAcceptedRelationReplayWindowFiltersASiblingRelationDeletedMidReplay() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 111UL); + const uint parentGuid = 0x70047000u; + const uint firstChildGuid = 0x70047001u; + const uint secondChildGuid = 0x70047002u; + + RuntimeEntityRecord firstChild = lifetime + .RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence(firstChild, out RuntimeInitialCreateResidenceLease firstLease)); + RuntimeEntityRecord secondChild = lifetime + .RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence(secondChild, out RuntimeInitialCreateResidenceLease secondLease)); + + // Both continuations admit while the FIRST parent incarnation is + // addressable, then the parent is orphaned once for both. + // includePosition:false + RemoveActive (not a full DeleteObject- + // acceptance) for the same reason AdmitThenOrphanParentRelation uses + // them: a real delete retains a permanent per-(guid,generation) + // teardown tombstone and a position-bearing retained snapshot would + // bleed into the SAME-incarnation "recovered CreateObject" merge + // when parentGuid is re-registered at incarnation 1 again below. + RuntimeEntityRecord orphanedParent = lifetime.RegisterEntity( + Spawn(parentGuid, 1, includePosition: false)).Canonical!; + var firstParentUpdate = new ParentEvent.Parsed( + parentGuid, firstChildGuid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 1, ChildPositionSequence: 2); + Assert.True(lifetime.TryApplyParent(firstParentUpdate, null, out _)); + var secondParentUpdate = new ParentEvent.Parsed( + parentGuid, secondChildGuid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 1, ChildPositionSequence: 2); + Assert.True(lifetime.TryApplyParent(secondParentUpdate, null, out _)); + Assert.True(lifetime.Entities.RemoveActive(orphanedParent)); + + _ = RunToCompletion(lifetime, firstChild, firstLease.Token, NoContact); + _ = RunToCompletion(lifetime, secondChild, secondLease.Token, NoContact); + + Assert.Equal(2, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Updated + || delta.Entity.Identity.ServerGuid != firstChildGuid) + { + return; + } + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(secondChildGuid, 1), isLocalPlayer: false, + removeRetainedObject: true, out RuntimeEntityDeleteAcceptance secondAcceptance)); + lifetime.CompleteAcceptedDelete(secondAcceptance); + + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, 1), isLocalPlayer: false, + removeRetainedObject: true, out RuntimeEntityDeleteAcceptance parentAcceptance)); + lifetime.CompleteAcceptedDelete(parentAcceptance); + })); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.False(lifetime.Entities.ParentAttachments + .ContainsDeferredAcceptedRelation(secondChildGuid, secondChild.Key!.Value)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Reviewer scenario (b), relation-queue flavor: C1's own attach-commit + // publish drives a full session clear - restore no-ops for C2's + // relation, converges. + [Fact] + public void DeferredAcceptedRelationReplayWindowReleasesSilentlyWhenSessionClearFiresMidReplay() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 112UL); + const uint parentGuid = 0x70048000u; + const uint firstChildGuid = 0x70048001u; + const uint secondChildGuid = 0x70048002u; + + RuntimeEntityRecord firstChild = lifetime + .RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence(firstChild, out RuntimeInitialCreateResidenceLease firstLease)); + RuntimeEntityRecord secondChild = lifetime + .RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence(secondChild, out RuntimeInitialCreateResidenceLease secondLease)); + + // includePosition:false + RemoveActive (not a full DeleteObject- + // acceptance) for the same reason AdmitThenOrphanParentRelation uses + // them: a real delete retains a permanent per-(guid,generation) + // teardown tombstone and a position-bearing retained snapshot would + // bleed into the SAME-incarnation "recovered CreateObject" merge + // when parentGuid is re-registered at incarnation 1 again below - + // and the reentrant BeginSessionClear below retires every currently + // active record (including the recreated parent), which would + // collide with a stale tombstone left by a full delete here. + RuntimeEntityRecord orphanedParent = lifetime.RegisterEntity( + Spawn(parentGuid, 1, includePosition: false)).Canonical!; + var firstParentUpdate = new ParentEvent.Parsed( + parentGuid, firstChildGuid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 1, ChildPositionSequence: 2); + Assert.True(lifetime.TryApplyParent(firstParentUpdate, null, out _)); + var secondParentUpdate = new ParentEvent.Parsed( + parentGuid, secondChildGuid, ParentLocation: 1u, PlacementId: 0u, + ParentInstanceSequence: 1, ChildPositionSequence: 2); + Assert.True(lifetime.TryApplyParent(secondParentUpdate, null, out _)); + Assert.True(lifetime.Entities.RemoveActive(orphanedParent)); + + _ = RunToCompletion(lifetime, firstChild, firstLease.Token, NoContact); + _ = RunToCompletion(lifetime, secondChild, secondLease.Token, NoContact); + + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Updated + || delta.Entity.Identity.ServerGuid != firstChildGuid) + { + return; + } + IReadOnlyList retirements = lifetime.BeginSessionClear(); + foreach (RuntimeEntityRecord record in retirements) + lifetime.CompleteSessionEntityRetirement(record); + })); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.Equal(0, lifetime.Entities.ParentAttachments.DeferredAcceptedRelationCount); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + } + + // Round 3 E-matrix (a)+(d): TRUE ParentAttachmentState semantics, verified + // by reading the source before writing this test. ParentAttachmentState. + // DeleteGeneration(parentGuid, oldIncarnation) - called from + // TryAcceptDelete when the PARENT is what's being deleted - only ever + // touches the _unresolvedByChild ParentEvent-relation queue (via + // FilterParentCandidates, keeping relations addressed to a STRICTLY + // newer parent incarnation). It never touches _deferredCreatesByParent, + // the raw-netblob bucket ReplayDeferredChildren/DetachDeferredCreates + // actually replays - that bucket is keyed ONLY by parent GUID, with no + // per-incarnation filtering at all (PhysicsAttachment carries no parent + // instance sequence to filter by). So a child queued while parent + // incarnation 1 was missing survives the parent's own delete untouched, + // and DOES replay against a same-GUID recreated incarnation 2 - this + // mirrors retail's own GUID-keyed netblob dispatch (see + // ReplayDeferredChildren's remarks: "a parent's own successful Create + // replays every blob queued waiting on ITS guid"), not a defect. + [Fact] + public void DeferredChildQueuedForDeletedParentIncarnationStillReplaysAgainstTheRecreatedParentGuid() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 50UL); + const uint parentGuid = 0x70030100u; + const uint childGuid = 0x70030000u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + // Parent incarnation 1 arrives, then is deleted, WITHOUT ever + // completing a residence (simulating "gone before the child's + // replay"). + RuntimeEntityRecord parentGen1 = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + Assert.False(lifetime.Entities.IsCurrent(parentGen1)); + // The deferred child create survives the parent's delete untouched - + // the TRUE, verified behavior. + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + // Parent GUID reused at a NEWER incarnation. + RuntimeEntityRecord parentGen2 = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 2, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parentGen2, out RuntimeInitialCreateResidenceLease parentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parentGen2, parentLease.Token, NoContact); + + Assert.Equal(1, receipt.ReplayedDeferredChildCount); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord child)); + Assert.True(lifetime.TryGetInitialCreateResidence(child, out _)); + } + + // Round 3 E-matrix (b): a child's own exact Delete before the parent's + // replay cancels only that child (CancelDeferredChildGeneration's side + // effect runs even though TryAcceptDelete's overall return is false for + // a guid with no seeded gate - a purely-deferred child was never + // registered, so it never got one). + [Fact] + public void ChildExactDeleteBeforeParentReplayCancelsOnlyThatChild() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 51UL); + const uint parentGuid = 0x70030200u; + const uint firstChildGuid = 0x70030300u; + const uint secondChildGuid = 0x70030400u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(firstChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(secondChildGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(2, lifetime.CaptureOwnership().DeferredParentCreateCount); + + // The purely-deferred child has no gate; TryAcceptDelete's overall + // gate-consuming path fails, but the deferred-create cancellation + // side effect at the top of the method already ran. + Assert.False(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(firstChildGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out _)); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + Assert.Equal(1, receipt.ReplayedDeferredChildCount); + Assert.False(lifetime.Entities.TryGetActive(firstChildGuid, out _)); + Assert.True(lifetime.Entities.TryGetActive(secondChildGuid, out _)); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + } + + // Round 3 E-matrix (c): two raw Creates queued for the SAME child guid + // (instance 1 and instance 2, both waiting on the same missing parent). + // Deleting the child at instance 1 cancels only the older, same-or-older + // queued generation; the strictly-newer instance 2 survives and replays. + [Fact] + public void NewerDeferredChildGenerationSurvivesOlderGenerationCleanupAndReplays() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 52UL); + const uint parentGuid = 0x70030500u; + const uint childGuid = 0x70030600u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 2, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(2, lifetime.CaptureOwnership().DeferredParentCreateCount); + + Assert.False(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(childGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out _)); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + Assert.Equal(1, receipt.ReplayedDeferredChildCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord child)); + Assert.Equal(2, child.Incarnation); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + } + + // Round 3 E-matrix (e): child GUID reuse across a COMPLETE lifecycle - + // register+replay+run-to-completion the first incarnation, delete it + // fully (gate removed), then reuse the SAME guid for a fresh incarnation + // that is AGAIN deferred (different missing parent) and replays cleanly + // with no leakage from the torn-down first generation. + [Fact] + public void ChildGuidReuseAfterFullLifecycleReplaysCleanlyWithoutStaleState() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 53UL); + const uint firstParentGuid = 0x70030700u; + const uint secondParentGuid = 0x70030800u; + const uint childGuid = 0x70030900u; + + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: firstParentGuid), + isLocalPlayer: false) + .DeferredForParent); + RuntimeEntityRecord firstParent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(firstParentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + firstParent, out RuntimeInitialCreateResidenceLease firstParentLease)); + _ = RunToCompletion(lifetime, firstParent, firstParentLease.Token, NoContact); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord firstChild)); + Assert.True(lifetime.TryGetInitialCreateResidence(firstChild, out RuntimeInitialCreateResidenceLease childLease)); + _ = RunToCompletion(lifetime, firstChild, childLease.Token, NoContact); + + // Fully delete the first incarnation (its gate is removed too). + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(childGuid, 1), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance deleteAcceptance)); + lifetime.CompleteAcceptedDelete(deleteAcceptance); + Assert.False(lifetime.Entities.TryGetActive(childGuid, out _)); + + // The SAME guid reused for a fresh incarnation, deferred behind a + // DIFFERENT missing parent. + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 2, includePosition: false, parentGuid: secondParentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + RuntimeEntityRecord secondParent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(secondParentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + secondParent, out RuntimeInitialCreateResidenceLease secondParentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, secondParent, secondParentLease.Token, NoContact); + + Assert.Equal(1, receipt.ReplayedDeferredChildCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord reusedChild)); + Assert.Equal(2, reusedChild.Incarnation); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + } + + // Round 3 E-matrix (f): a full session reset before the missing parent + // ever arrives clears the deferred-create bucket to zero. + [Fact] + public void SessionResetBeforeParentArrivalClearsDeferredBucket() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 54UL); + const uint parentGuid = 0x70030A00u; + const uint childGuid = 0x70030B00u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + IReadOnlyList retirements = lifetime.BeginSessionClear(); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + foreach (RuntimeEntityRecord record in retirements) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + } + + // Round 3 E-matrix (g): a full session reset triggered REENTRANTLY from + // inside the deferred-child replay's own Registered callback. The + // parent's own in-flight Execute() must abandon cleanly rather than + // resurrect anything, and the session-clear transaction itself must + // still converge once its own retirements are drained. + [Fact] + public void SessionResetFromWithinReplayDrivenCallbackConverges() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 55UL); + const uint parentGuid = 0x70030C00u; + const uint childGuid = 0x70030D00u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + IReadOnlyList? retirements = null; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Registered + || delta.Entity.Identity.ServerGuid != childGuid + || retirements is not null) + { + return; + } + retirements = lifetime.BeginSessionClear(); + })); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt); + + // The parent's own in-flight execution is no longer current once the + // reentrant reset retires everything - a typed abandonment, not a + // resurrection or an escaping exception. + Assert.Equal(RuntimeInitialCreateExecutionStatus.RejectedAuthority, status); + Assert.Equal(default, receipt); + Assert.NotNull(retirements); + foreach (RuntimeEntityRecord record in retirements!) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + } + + // Round 3 E-matrix (h): instance sequence zero is a normal retail + // timestamp, not an empty sentinel (see ParentAttachmentState's own + // remarks on CancelDeferredChildGeneration) - a deferred child at + // incarnation 0 must replay exactly like any other incarnation. + [Fact] + public void InstanceSequenceZeroChildReplaysCorrectly() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 56UL); + const uint parentGuid = 0x70030E00u; + const uint childGuid = 0x70030F00u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 0, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + Assert.Equal(1, lifetime.CaptureOwnership().DeferredParentCreateCount); + + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, out RuntimeInitialCreateResidenceLease parentLease)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, parent, parentLease.Token, NoContact); + + Assert.Equal(1, receipt.ReplayedDeferredChildCount); + Assert.Equal(0, lifetime.CaptureOwnership().DeferredParentCreateCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord child)); + Assert.Equal(0, child.Incarnation); + Assert.True(lifetime.TryGetInitialCreateResidence(child, out _)); + } + + // --------------------------------------------------------------- + // F. Failure boundaries. + // + // (b) delete from the hook-emission window (after InitialAdoption, + // before the FIFO drain) has NO reachable callback of its own: + // RunInitialTail's Adopted phase (InitialAdoption) and HookRecorded + // phase (the AfterEnterWorld teleport-hook trace entry) are both pure + // trace-only mutations with no event dispatch - there is no observer + // boundary between them. The ONLY publish inside RunInitialTail is + // ReplayDeferredChildren's per-child Registered event (the + // DeferredReplayed phase), which already has dedicated coverage: + // DeleteDuringDeferredChildReplayAbandonsExecutionWithoutResurrection + // (above, in section G) deletes reentrantly from exactly that callback + // and proves the abandonment converges without resurrecting anything. + // Adding a second, structurally-identical test here would only + // duplicate that coverage; the closest reachable boundary already has a + // test. + // --------------------------------------------------------------- + + [Fact] + public void DeleteFromObserverBeforeInitialAdoptionAbandonsFirstExecuteCleanly() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 60UL); + const uint guid = 0x70031000u; + + // RegisterEntityCore's InitializeAcceptedCreateResidence (Begin()) + // runs BEFORE PublishEntity(Registered, ...) - a real lease/token + // already exists by the time this observer fires, so it can be + // captured and later handed to Execute() to observe that FIRST + // call's own outcome directly, rather than merely proving no lease + // survives. + RuntimeEntityRecord? capturedCanonical = null; + RuntimeInitialCreateResidenceLease capturedLease = default; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Registered + || delta.Entity.Identity.ServerGuid != guid) + { + return; + } + Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord active)); + capturedCanonical = active; + Assert.True(lifetime.TryGetInitialCreateResidence(active, out capturedLease)); + // Reentrant delete BEFORE Execute() is ever called for this + // entity at all - before RunInitialTail's InitialAdoption phase, + // the very first thing an Execute() call would otherwise do. + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(guid, active.Incarnation), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + })); + + // The reentrant delete bumps the lifetime-mutation counter for this + // guid DURING the Registered publish, so RegisterEntityCore's own + // post-publish currency check reports this registration as + // superseded (Canonical: null in the returned result) - the record + // captured from inside the observer, above, is the one to assert + // against. + _ = lifetime.RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false); + Assert.NotNull(capturedCanonical); + RuntimeEntityRecord canonical = capturedCanonical!; + Assert.False(lifetime.Entities.IsCurrent(canonical)); + // TryAcceptDelete's own ForgetInitialCreateResidence already retired + // the residence reentrantly - nothing was ever adopted. + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + var observed = new List(); + using IDisposable secondSubscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + // The FIRST (and only) Execute() call for this entity, using the + // token captured before the reentrant delete: the residence is + // already gone (RejectedToken, not RejectedAuthority - there was + // never a Progress to Abandon), publishes nothing new, and + // converges. + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute( + canonical, capturedLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.Empty(observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void DeleteFromObserverBetweenFifoEntriesAppliesFirstOnlyAndConverges() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 61UL); + const uint guid = 0x70031100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x09000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + var vector = new VectorUpdate.Parsed( + guid, new Vector3(11f, 12f, 13f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + var observed = new List(); + bool deletedFromObserver = false; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + observed.Add(delta.Change); + if (deletedFromObserver || delta.Change is not RuntimeEntityChange.Updated) + return; + deletedFromObserver = true; + // Between FIFO entry 1 (ObjDesc, already applied+published) and + // entry 2 (Vector, not yet reached) - delete reentrantly. + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(guid, canonical.Incarnation), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + })); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.Equal(RuntimeInitialCreateExecutionStatus.RejectedAuthority, status); + Assert.Equal(default, receipt); + // ObjDesc applied+published exactly once; Vector never applied + // (canonical's velocity stays at its pre-drain value); the delete's + // own Deleted is the only other observed event. + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Deleted], + observed); + Assert.Equal(0x09000002u, canonical.Snapshot.BasePaletteId); + Assert.Null(canonical.Snapshot.Physics!.Value.Velocity); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + // A stale retry never resurrects anything or reapplies Vector. + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt retryReceipt)); + Assert.Equal(default, retryReceipt); + Assert.Null(canonical.Snapshot.Physics!.Value.Velocity); + } + + // Round 3 F(d): an observer that throws during a publish must not + // corrupt the drain or escape Execute() - RuntimeEntityObjectEventStream. + // Dispatch contains each observer's exception per-call (RecordDispatchFailure) + // and continues to the next observer/pending item. Pin that actual, + // verified behavior here rather than assuming propagation. + [Fact] + public void ObserverThrowDuringPublishIsContainedAndDrainConvergesWithNoDuplicateRetry() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 62UL); + const uint guid = 0x70031200u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + var vector = new VectorUpdate.Parsed( + guid, Vector3.One, Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + throw new InvalidOperationException("Deliberate observer failure for F(d)."))); + long failuresBefore = lifetime.Events.DispatchFailureCount; + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.Vector, + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(Vector3.One, canonical.Snapshot.Physics!.Value.Velocity); + Assert.True(lifetime.Events.DispatchFailureCount > failuresBefore); + Assert.NotNull(lifetime.Events.LastDispatchFailure); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + // A stale retry after the (contained) throw does not duplicate any + // side effect. + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedToken, + lifetime.InitialCreateExecution.Execute(canonical, lease.Token, NoContact, out _)); + Assert.Equal(Vector3.One, canonical.Snapshot.Physics!.Value.Velocity); + } + + // --------------------------------------------------------------- + // G. Reentrancy + // --------------------------------------------------------------- + + [Fact] + public void ReentrantExecuteForTheSameEntityFailsClosedRatherThanInterleaving() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 11UL); + const uint guid = 0x70026000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + + var reentrantStatuses = new List(); + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Updated || reentrantStatuses.Count != 0) + return; + // Firing a nested Execute from inside an event observer while the + // outer Execute is still on the stack must fail closed. + reentrantStatuses.Add(lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _)); + })); + var vector = new VectorUpdate.Parsed( + guid, Vector3.One, Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + Assert.Equal( + [RuntimeInitialCreateExecutionStatus.RejectedAuthority], + reentrantStatuses); + Assert.NotEmpty(receipt.Trace); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void DeleteDuringDeferredChildReplayAbandonsExecutionWithoutResurrection() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 12UL); + const uint parentGuid = 0x70026100u; + const uint childGuid = 0x70026200u; + Assert.True(lifetime.RegisterEntityWithInitialResidence( + Spawn(childGuid, 1, includePosition: false, parentGuid: parentGuid), + isLocalPlayer: false) + .DeferredForParent); + RuntimeEntityRecord parent = lifetime + .RegisterEntityWithInitialResidence( + Spawn(parentGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + parent, + out RuntimeInitialCreateResidenceLease parentLease)); + + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Registered + || delta.Entity.Identity.ServerGuid != childGuid) + { + return; + } + // A reentrant delete of the PARENT itself, triggered from inside + // the child-registration callback the deferred replay drives. + Assert.True(lifetime.TryAcceptDelete( + new DeleteObject.Parsed(parentGuid, parent.Incarnation), + isLocalPlayer: false, + removeRetainedObject: true, + out RuntimeEntityDeleteAcceptance acceptance)); + lifetime.CompleteAcceptedDelete(acceptance); + })); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + parent, parentLease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.Equal(RuntimeInitialCreateExecutionStatus.RejectedAuthority, status); + Assert.Equal(default, receipt); + Assert.False(lifetime.Entities.IsCurrent(parent)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + // Only the PARENT's own residence/progress was ever touched by this + // abandoned Execute call. The CHILD is a distinct entity the + // deferred replay legitimately registered through the canonical + // route before the reentrant delete happened - its own (never + // executed) residence lease correctly remains open; deleting the + // parent does not cascade-tear-down an unrelated child. + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.True(lifetime.Entities.TryGetActive(childGuid, out RuntimeEntityRecord child)); + Assert.True(lifetime.TryGetInitialCreateResidence(child, out _)); + } + + // Round 3 B2 regression (reentrancy from a publish callback): a wire + // apply (TryApplyMotion) reentrantly enqueued from inside an EARLIER + // continuation's own publish must NOT retire the residence, and the + // continuation that was ALREADY queued before the drain even started + // (Vector) must still drain - proving the reentrant enqueue never + // displaces or skips the pre-existing FIFO tail. + [Fact] + public void WireApplyDuringDrainPublishDoesNotRetireResidenceAndPreExistingFifoStillDrains() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 63UL); + const uint guid = 0x70031300u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x0A000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + var vector = new VectorUpdate.Parsed( + guid, new Vector3(21f, 22f, 23f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + var observed = new List(); + bool reentered = false; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + observed.Add(delta.Change); + if (reentered || delta.Change is not RuntimeEntityChange.Updated) + return; + reentered = true; + var motion = new WorldSession.EntityMotionUpdate( + guid, + new CreateObject.ServerMotionState(0x3d, 0x11), + InstanceSequence: 1, + MovementSequence: 2, + ServerControlSequence: 1, + IsAutonomous: false); + Assert.True(lifetime.TryApplyMotion(motion, retainPayload: true, null, out _, out _)); + })); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + // Vector (already queued before the drain started) drains BEFORE + // the reentrantly-enqueued Motion, and both drain exactly once. + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.ObjDesc, + RuntimeInitialCreateExecutedActionKind.Vector, + RuntimeInitialCreateExecutedActionKind.Movement, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Updated, RuntimeEntityChange.Updated], + observed); + Assert.Equal(new Vector3(21f, 22f, 23f), canonical.Snapshot.Physics!.Value.Velocity); + Assert.Equal(new CreateObject.ServerMotionState(0x3d, 0x11), canonical.Snapshot.MotionState); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void RegisterDifferentEntityFromCallbackDuringDrainConvergesBothIndependently() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 64UL); + const uint firstGuid = 0x70031400u; + const uint secondGuid = 0x70031500u; + RuntimeEntityRecord first = lifetime + .RegisterEntityWithInitialResidence( + Spawn(firstGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + first, out RuntimeInitialCreateResidenceLease firstLease)); + var vector = new VectorUpdate.Parsed( + firstGuid, Vector3.One, Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + RuntimeEntityRecord? secondCanonical = null; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is not RuntimeEntityChange.Updated + || delta.Entity.Identity.ServerGuid != firstGuid + || secondCanonical is not null) + { + return; + } + // A completely unrelated entity registered reentrantly, from + // inside the FIRST entity's own drain publish. + secondCanonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(secondGuid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + })); + + RuntimeInitialCreateExecutionReceipt firstReceipt = RunToCompletion( + lifetime, first, firstLease.Token, NoContact); + + Assert.NotEmpty(firstReceipt.Trace); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.NotNull(secondCanonical); + Assert.True(lifetime.Entities.IsCurrent(secondCanonical!)); + Assert.True(lifetime.TryGetInitialCreateResidence(secondCanonical!, out RuntimeInitialCreateResidenceLease secondLease)); + + // The second entity's own independent residence still drains + // normally afterward. + RuntimeInitialCreateExecutionReceipt secondReceipt = RunToCompletion( + lifetime, secondCanonical!, secondLease.Token, NoContact); + Assert.Contains( + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + secondReceipt.Trace.Select(static a => a.Kind)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 3 G(c)/I2: recreating the SAME guid at a NEWER incarnation from + // a reentrant callback mid-drain. The old execution must abandon + // cleanly (its own progress+residence converge) with NO FIFO transfer + // to the new incarnation; the new incarnation is a completely + // independent, unaffected registration. This is the same scenario the + // task's "replacement convergence" item names - one test covers both. + [Fact] + public void RecreateSameGuidNewerIncarnationFromCallbackAbandonsOldExecutionWithoutFifoTransfer() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 65UL); + const uint guid = 0x70031600u; + RuntimeEntityRecord canonicalGen1 = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonicalGen1, out RuntimeInitialCreateResidenceLease lease)); + + var appearance = new ObjDescEvent.Parsed( + guid, + new CreateObject.ModelData( + 0x0B000002u, + Array.Empty(), + Array.Empty(), + Array.Empty()), + InstanceSequence: 1, + ObjDescSequence: 2); + Assert.True(lifetime.TryApplyObjDesc(appearance, null, out _)); + var vector = new VectorUpdate.Parsed( + guid, new Vector3(31f, 32f, 33f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + bool recreated = false; + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (recreated || delta.Change is not RuntimeEntityChange.Updated) + return; + recreated = true; + // Same guid, NEWER incarnation, from inside the FIRST + // continuation's (ObjDesc's) own publish - a bare Create with no + // residence, to isolate the replacement mechanics from any + // second drain. + _ = lifetime.RegisterEntity(Spawn(guid, 2, includePosition: false)); + })); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonicalGen1, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt); + + Assert.Equal(RuntimeInitialCreateExecutionStatus.RejectedAuthority, status); + Assert.Equal(default, receipt); + Assert.False(lifetime.Entities.IsCurrent(canonicalGen1)); + // Old progress+residence fully converge. + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + // The new incarnation is unaffected: no residence pending (a bare + // RegisterEntity never begins one), and none of generation 1's + // FIFO (Vector, still undrained) ever transferred to it. + Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord canonicalGen2)); + Assert.Equal(2, canonicalGen2.Incarnation); + Assert.False(lifetime.TryGetInitialCreateResidence(canonicalGen2, out _)); + Assert.Null(canonicalGen2.Snapshot.Physics!.Value.Velocity); + } + + // Round 3 G(d): Dispose() called reentrantly from a publish callback. + // Pin the ACTUAL observed behavior (verified by running this test) + // rather than assuming either containment or propagation. + [Fact] + public void DisposeFromCallbackDuringDrainConvergesTheCompleteLedger() + { + var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 66UL); + const uint guid = 0x70031700u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + var vector = new VectorUpdate.Parsed( + guid, Vector3.One, Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + if (delta.Change is RuntimeEntityChange.Updated) + lifetime.Dispose(); + })); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt); + + // Dispose() reentrantly retires everything (BeginSessionClear -> + // teardown -> converge) before the outer Execute() call's own + // currency check runs again; that check sees a no-longer-current + // canonical and abandons in the SAME typed way any other reentrant + // teardown does. No invalid enumeration/exception escapes. + Assert.Equal(RuntimeInitialCreateExecutionStatus.RejectedAuthority, status); + Assert.Equal(default, receipt); + Assert.True(lifetime.CaptureOwnership().IsConverged); + } + + // --------------------------------------------------------------- + // H. Saturation / stale-progress + // --------------------------------------------------------------- + + [Fact] + public void StaleProgressLeaseIdIsDiscardedAndFailsClosedThenRetrySucceedsCleanly() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 13UL); + const uint guid = 0x70027000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + RuntimeEntityKey key = canonical.Key!.Value; + + // Directly plant a stale Progress entry under a LeaseId that does + // NOT match the current lease's token - the exact "an old + // incarnation's progress leaking into a reused key" scenario the + // contract requires Execute to discard and fail closed on for this + // one call, never silently reusing it. + PlantStaleProgress(lifetime, key, lease.Token.LeaseId + 1_000UL); + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute(canonical, lease.Token, NoContact, out _)); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + Assert.NotEmpty(receipt.Trace); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + private static void PlantStaleProgress( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityKey key, + ulong staleLeaseId) + { + Type progressType = typeof(RuntimeInitialCreateContinuationExecutor) + .GetNestedType("Progress", System.Reflection.BindingFlags.NonPublic)!; + object stale = System.Runtime.CompilerServices.RuntimeHelpers + .GetUninitializedObject(progressType); + System.Reflection.PropertyInfo leaseIdProperty = progressType.GetProperty( + "LeaseId", + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic)!; + leaseIdProperty.SetValue(stale, staleLeaseId); + System.Reflection.FieldInfo progressField = typeof(RuntimeInitialCreateContinuationExecutor) + .GetField( + "_progress", + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.NonPublic)!; + var dictionary = (System.Collections.IDictionary)progressField.GetValue( + lifetime.InitialCreateExecution)!; + dictionary[key] = stale; + } + + // Round 3 H (saturation): adoption Revision pinned at ulong.MaxValue - + // CanEnqueue's completed-entry branch requires + // "Revision < ulong.MaxValue", so a NEW continuation fails closed BEFORE + // any wire consumption; whatever was ALREADY committed before saturation + // still drains and converges normally through the executor. + [Fact] + public void AdoptionRevisionSaturationFailsClosedBeforeAnyNewContinuationCanEnqueue() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 70UL); + const uint guid = 0x70032000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + var firstVector = new VectorUpdate.Parsed( + guid, Vector3.One, Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(firstVector, null, out _)); + + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.InitialCreateResidences.Complete(canonical, lease.Token, out _)); + SetCompletedAdoptionRevision(lifetime, canonical.Key!.Value, ulong.MaxValue); + + var secondVector = new VectorUpdate.Parsed( + guid, new Vector3(2f, 2f, 2f), Vector3.Zero, InstanceSequence: 1, VectorSequence: 3); + Assert.False(lifetime.TryApplyVector(secondVector, null, out _)); + Assert.True(lifetime.InitialCreateResidences.TryGetTransaction( + canonical, out RuntimeInitialCreateResidenceLease afterFailedEnqueue)); + Assert.Single(afterFailedEnqueue.Continuations); + + RuntimeInitialCreateExecutionReceipt receipt = RunToCompletion( + lifetime, canonical, lease.Token, NoContact); + + // Only the ONE continuation committed before saturation ever drains - + // exactly once, never duplicated by the failed second attempt. + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.Vector, + ], + receipt.Trace.Select(static a => a.Kind)); + Assert.Equal(Vector3.One, canonical.Snapshot.Physics!.Value.Velocity); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + /// + /// Local copy of RuntimeInitialCreateResidenceStateTests' + /// SetCompletedAdoptionRevision reflection helper (per this task's + /// explicit instruction to reuse the pattern locally rather than share + /// test-project internals across files). + /// + private static void SetCompletedAdoptionRevision( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityKey key, + ulong revision) + { + Type stateType = typeof(RuntimeInitialCreateResidenceState); + System.Reflection.FieldInfo completedField = stateType.GetField( + "_completed", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + var completed = (System.Collections.IDictionary)completedField.GetValue( + lifetime.InitialCreateResidences)!; + object entry = completed[key]!; + System.Reflection.PropertyInfo receiptProperty = entry.GetType().GetProperty( + "Receipt", + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.NonPublic)!; + var receipt = (RuntimeInitialCreateResidenceReceipt)receiptProperty.GetValue(entry)!; + receiptProperty.SetValue( + entry, + receipt with { Adoption = receipt.Adoption with { Revision = revision } }); + } + + // --------------------------------------------------------------- + // External-race detection on the executor's staleness baseline + // (regression coverage for the narrowed pre-Complete + // AdvanceExecutorBaseline guard - Finding 1) + // --------------------------------------------------------------- + + [Fact] + public void ExternalPositionAuthorityMutationWithNoPendingPlacementFailsClosedAndConverges() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 16UL); + const uint guid = 0x70029000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + // Drive the residence directly to "completed + adopted" - the exact + // state RunInitialTail leaves behind before draining any + // continuation, with NO pending continuation placement. This is + // the only state in which the narrowed pre-Complete + // AdvanceExecutorBaseline guard does NOT resync the baseline + // before the next Complete() call runs. + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.InitialCreateResidences.Complete(canonical, lease.Token, out _)); + Assert.True(lifetime.InitialCreateResidences.AdoptCompletedPlacement(canonical, lease.Token)); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + // External, non-executor mutation of one of the four baseline + // fields (never through AdvanceExecutorBaseline) - simulating a + // genuine race between Execute calls with nothing in flight. Before + // Finding 1's fix, the OLD unconditional pre-Complete rebaseline + // would have silently absorbed this and returned Completed instead. + lifetime.Entities.AdvancePositionAuthority(canonical); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.Empty(observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + [Fact] + public void ExternalFullCellMutationWithNoPendingPlacementFailsClosedAndConverges() + { + using RuntimeEntityObjectLifetime lifetime = new(); + Bind(lifetime, 17UL); + const uint guid = 0x70029100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + + Assert.Equal( + RuntimeInitialCreateResidenceCompletionStatus.Completed, + lifetime.InitialCreateResidences.Complete(canonical, lease.Token, out _)); + Assert.True(lifetime.InitialCreateResidences.AdoptCompletedPlacement(canonical, lease.Token)); + + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe( + new EntityObserver(delta => observed.Add(delta.Change))); + + // FullCellId is the second field the reviewer explicitly named - + // exercise it independently of PositionAuthorityVersion. + lifetime.Entities.SetFullCell(canonical, Cell, Landblock); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.Empty(observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + } + + // Round 4 R4-2 / R4-9(b): the reviewer's exact scenario - external + // SetFullCell DURING the AwaitingContinuationPlacement window (after the + // continuation's OWN placement has been acknowledged but before the + // executor consumes it), not merely with no placement in flight at all + // (that is the PRECEDING test above). Before the fix, both failure arms + // in ResumePendingPlacement cleared PendingContinuationPlacement and + // abandoned WITHOUT ever forgetting the retained acknowledged-placement + // entry - HasRetainedCompletion for this key would then stay true + // forever, blocking EVERY later placement begin (the runtime-surface.md + // 3.1 deadlock this executor exists to resolve). + [Fact] + public void ExternalFullCellMutationDuringAwaitingContinuationPlacementForgetsThePendingPlacementAndAllowsAFreshOneToBegin() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 90UL); + const uint guid = 0x70034000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, true, null, out _, out _, out _)); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + // Drives the continuation's own placement through prepare/submit/ + // acknowledge - the acknowledged projection is now retained under + // this exact token, ready for the NEXT Execute call to consume. + CompletePendingContinuationPlacement(lifetime, key, route); + + // External mutation of the record's cell AFTER the continuation's + // own placement was acknowledged but BEFORE the executor consumes + // it - ResumePendingPlacement's projection/record agreement check + // must catch this exactly like Complete() does for the initial + // placement. + lifetime.Entities.SetFullCell(canonical, canonical.FullCellId + 999u, Landblock); + + Assert.Equal( + RuntimeInitialCreateExecutionStatus.RejectedAuthority, + lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out RuntimeInitialCreateExecutionReceipt receipt)); + Assert.Equal(default, receipt); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + RuntimeSetPositionOwnershipSnapshot physicsOwnership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(0, physicsOwnership.ActiveOperationCount); + // The central R4-2 claim: no retained-completion leak. + Assert.Equal(0, physicsOwnership.AcknowledgedPlacementCompletionCount); + + // A subsequent FRESH authored placement for the SAME entity can + // begin - proves HasRetainedCompletion no longer blocks it. + RuntimeEntityPlacementToken fresh = lifetime.Physics.SetPosition + .TryBeginExclusiveAuthoredPlacement( + canonical, + canonical.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative); + Assert.True(fresh.IsValid); + } + + // Round 4 R4-9(a): guards B3's residence-retirement notification edge + // for a placement STILL IN FLIGHT (not yet acknowledged) - a THIRD + // PARTY (not the executor) discovers residence staleness through the + // residence's own host-facing TryGetTransaction query, which internally + // Retires the completed entry. That Retire must notify the executor so + // it forgets its OWN separately-tracked pending continuation placement + // token, not just the residence's initial-lease placement. + [Fact] + public void ThirdPartyTryGetTransactionRetireWhileAwaitingContinuationPlacementDiscardsExecutorProgressAndForgetsThePendingPlacement() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 91UL); + const uint guid = 0x70034100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, true, null, out _, out _, out _)); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationPlacement(key, out RuntimeEntityPlacementToken pendingPlacement)); + Assert.True(lifetime.Physics.SetPosition.IsPlacementCurrent(pendingPlacement)); + + // External, non-executor mutation of one of the four executor- + // tracked baseline fields - simulates something OTHER than the + // executor discovering staleness through a THIRD-PARTY call to the + // residence's own host-facing TryGetTransaction, never through + // Execute at all. + lifetime.Entities.AdvancePositionAuthority(canonical); + + Assert.False(lifetime.InitialCreateResidences.TryGetTransaction(canonical, out _)); + + // B3's notification edge: the residence's own Retire (triggered by + // a THIRD PARTY, not the executor) must notify the executor so it + // forgets the pending continuation placement, not just the + // residence's own initial-lease placement. + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.False(lifetime.Physics.SetPosition.IsPlacementCurrent(pendingPlacement)); + RuntimeSetPositionOwnershipSnapshot physicsOwnership = + lifetime.Physics.SetPosition.CaptureOwnership(); + Assert.Equal(0, physicsOwnership.ActiveOperationCount); + Assert.Equal(0, physicsOwnership.PlacementCompletionWatchCount); + Assert.Equal(0, physicsOwnership.AcknowledgedPlacementCompletionCount); + + // A subsequent FRESH authored placement for the SAME entity can + // begin - no lingering block from the forgotten pending placement. + RuntimeEntityPlacementToken fresh = lifetime.Physics.SetPosition + .TryBeginExclusiveAuthoredPlacement( + canonical, + canonical.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.LocalAuthoritative); + Assert.True(fresh.IsValid); + } + + // --------------------------------------------------------------- + // I. Ownership convergence + // --------------------------------------------------------------- + + [Fact] + public void ResetDuringAwaitingContinuationPlacementConvergesEveryLedger() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 14UL); + const uint guid = 0x70028000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: true) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: true, null, null, true, null, out _, out _, out _)); + + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, NoContact, out _); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + IReadOnlyList retirements = lifetime.BeginSessionClear(); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + foreach (RuntimeEntityRecord record in retirements) + lifetime.CompleteSessionEntityRetirement(record); + Assert.True(lifetime.CompleteSessionClearIfConverged()); + } + + [Fact] + public void DisposalConvergesTheCompleteOwnershipLedgerAfterASuccessfulExecution() + { + var lifetime = new RuntimeEntityObjectLifetime(); + Bind(lifetime, 15UL); + const uint guid = 0x70028100u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence( + Spawn(guid, 1, includePosition: false), + isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, + out RuntimeInitialCreateResidenceLease lease)); + _ = RunToCompletion(lifetime, canonical, lease.Token, NoContact); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + + lifetime.Dispose(); + + Assert.True(lifetime.CaptureOwnership().IsConverged); + } + + // --------------------------------------------------------------- + // J. Gap closures (B1 operation-slot contention). + // --------------------------------------------------------------- + + // Round 3 B1-gap: a Position continuation's own placement-begin can fail + // on TRANSIENT operation-slot contention (another operation already owns + // this entity's SetPosition slot) rather than genuine staleness. The + // merge+publish must already have committed by that point (this is NOT + // an abandonment); a retry after the slot frees must complete WITHOUT + // re-running the merge or re-publishing. + [Fact] + public void OperationSlotContentionYieldsRetryableWithoutAbandoningThenRetryCompletesWithNoDuplicatePublish() + { + using RuntimeEntityObjectLifetime lifetime = EngineLifetime(); + Bind(lifetime, 80UL); + const uint guid = 0x70033000u; + RuntimeEntityRecord canonical = lifetime + .RegisterEntityWithInitialResidence(Spawn(guid, 1), isLocalPlayer: false) + .Canonical!; + Assert.True(lifetime.TryGetInitialCreateResidence( + canonical, out RuntimeInitialCreateResidenceLease lease)); + AttachDormantBody(lifetime, canonical); + CompleteInitialPlacement(lifetime, lease); + Assert.Equal(Cell, canonical.FullCellId); + + // Entry 1: a Vector continuation whose own publish is the injection + // point for occupying the entity's SetPosition slot from OUTSIDE the + // executor - by the time this fires, RunInitialTail's + // AdoptCompletedPlacement has already freed the slot the INITIAL + // placement held, so an external begin here genuinely succeeds. + var vector = new VectorUpdate.Parsed( + guid, Vector3.One, Vector3.Zero, InstanceSequence: 1, VectorSequence: 2); + Assert.True(lifetime.TryApplyVector(vector, null, out _)); + + // Entry 2: a far-distance Remote Position continuation that will + // require its OWN real SetPosition placement. + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f); + Assert.True(lifetime.TryApplyPosition( + update, isLocalPlayer: false, null, null, false, null, + out PositionTimestampDisposition disposition, out _, out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + + RuntimeEntityPlacementToken unrelatedOccupant = default; + var observed = new List(); + using IDisposable subscription = lifetime.Events.Subscribe(new EntityObserver(delta => + { + observed.Add(delta.Change); + if (unrelatedOccupant.IsValid || delta.Change is not RuntimeEntityChange.Updated) + return; + unrelatedOccupant = lifetime.Physics.SetPosition.TryBeginExclusiveAuthoredPlacement( + canonical, + canonical.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + Assert.True(unrelatedOccupant.IsValid); + })); + + var inputs = new RuntimeInitialCreateExecutionInputs( + UsePositionFromServer: false, PlayerDistance: 200f); + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, inputs, out RuntimeInitialCreateExecutionReceipt receipt); + + // Contention, not staleness: retryable, residence/progress both + // still open (no abandonment). + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, status); + Assert.Equal(default, receipt); + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(1, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + // The merge+publish for BOTH entries already committed before the + // contention was even discovered. + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Updated], + observed); + Assert.Equal(25f, canonical.Snapshot.Position!.Value.PositionX); + + // Free the slot; retry. + lifetime.Physics.SetPosition.PublishCancellation( + lifetime.Physics.SetPosition.ForgetExactPlacement(unrelatedOccupant)); + + RuntimeInitialCreateExecutionStatus retryStatus = lifetime.InitialCreateExecution.Execute( + canonical, lease.Token, inputs, out RuntimeInitialCreateExecutionReceipt retryReceipt); + Assert.Equal(RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement, retryStatus); + Assert.Equal(default, retryReceipt); + // No re-merge, no re-publish on the contention retry itself. + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Updated], + observed); + + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + Assert.Equal(RuntimeAuthoritativePositionDisposition.SetPositionSimple, route.Disposition); + CompletePendingContinuationPlacement(lifetime, key, route); + + RuntimeInitialCreateExecutionReceipt finalReceipt = RunToCompletion( + lifetime, canonical, lease.Token, inputs); + + Assert.Equal( + [ + RuntimeInitialCreateExecutedActionKind.InitialAdoption, + RuntimeInitialCreateExecutedActionKind.Vector, + RuntimeInitialCreateExecutedActionKind.Position, + ], + finalReceipt.Trace.Select(static a => a.Kind)); + // Still exactly two publishes across the entire contention + + // retry + completion sequence. + Assert.Equal( + [RuntimeEntityChange.Updated, RuntimeEntityChange.Updated], + observed); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateResidenceLeaseCount); + Assert.Equal(0, lifetime.CaptureOwnership().InitialCreateExecutorProgressCount); + Assert.Equal(0, lifetime.Physics.SetPosition.CaptureOwnership().ActiveOperationCount); + } + + // --------------------------------------------------------------- + // Harness + // --------------------------------------------------------------- + + private static RuntimeEntityObjectLifetime EngineLifetime() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return new RuntimeEntityObjectLifetime(engine); + } + + private static RuntimeInitialCreateExecutionReceipt RunToCompletion( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord canonical, + in RuntimeInitialCreateResidenceToken token, + RuntimeInitialCreateExecutionInputs inputs, + int maxSteps = 25) + { + for (int step = 0; step < maxSteps; step++) + { + RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( + canonical, token, inputs, out RuntimeInitialCreateExecutionReceipt receipt); + switch (status) + { + case RuntimeInitialCreateExecutionStatus.Completed: + return receipt; + case RuntimeInitialCreateExecutionStatus.AwaitingContinuationPlacement: + { + RuntimeEntityKey key = canonical.Key!.Value; + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationRoute(key, out RuntimeAuthoritativePositionRoute route)); + CompletePendingContinuationPlacement(lifetime, key, route); + continue; + } + default: + throw new InvalidOperationException( + $"RunToCompletion hit unexpected status {status}; drive its precondition explicitly instead."); + } + } + throw new InvalidOperationException("RunToCompletion exceeded its step budget."); + } + + private static void CompleteInitialPlacement( + RuntimeEntityObjectLifetime lifetime, + in RuntimeInitialCreateResidenceLease lease) + { + RuntimeSetPositionCommand command = Prepare( + lifetime, + lease.Placement, + lease.Route.OperationKind, + lease.Route.SetPositionFlags); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(lease.Placement, command); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(outcome.Projection)); + } + + private static void CompletePendingContinuationPlacement( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityKey key, + in RuntimeAuthoritativePositionRoute route) + { + Assert.True(lifetime.InitialCreateExecution + .TryGetPendingContinuationPlacement(key, out RuntimeEntityPlacementToken placement)); + RuntimeSetPositionCommand command = Prepare( + lifetime, placement, route.OperationKind, route.SetPositionFlags); + RuntimeSetPositionOutcome outcome = lifetime.Physics.SetPosition + .SubmitPreparedPlacement(placement, command); + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + Assert.True(lifetime.Physics.SetPosition.AcknowledgeProjection(outcome.Projection)); + } + + private static RuntimeSetPositionCommand Prepare( + RuntimeEntityObjectLifetime lifetime, + in RuntimeEntityPlacementToken placement, + RuntimeSetPositionOperationKind operationKind, + PhysicsSetPositionFlags flags) + { + var preparation = new RuntimeSetPositionMoverPreparation( + RuntimeSetPositionMoverSetup.ResolvedAbsent, + operationKind, + GameTime: 1d, + PhysicsPlacementClass.Ordinary, + flags); + Assert.Equal( + RuntimeSetPositionMoverPreparationStatus.Prepared, + lifetime.Physics.SetPosition.PrepareMover( + placement, preparation, out RuntimeSetPositionCommand command)); + return command; + } + + private static void AttachDormantBody( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord canonical, + bool inContact = false) + { + var body = new PhysicsBody + { + State = canonical.FinalPhysicsState, + Orientation = Quaternion.Identity, + InWorld = false, + TransientState = inContact ? TransientStateFlags.Contact : TransientStateFlags.None, + }; + lifetime.Entities.SetPhysicsBody(canonical, body); + } + + /// + /// The real physics sweep an initial-placement commit runs against the + /// test landblock decides the body's own contact bit from scratch, + /// clobbering whatever set beforehand. + /// Call this AFTER the placement commits to pin the contact bit a + /// Position-route test actually needs. + /// + private static void ForceContact(RuntimeEntityRecord canonical, bool inContact) + { + if (canonical.PhysicsBody is not { } body) + return; + body.TransientState = inContact + ? body.TransientState | TransientStateFlags.Contact + : body.TransientState & ~TransientStateFlags.Contact; + } + + private static void Bind(RuntimeEntityObjectLifetime lifetime, ulong generation) + { + var token = new RuntimeGenerationToken(generation); + lifetime.BindEventContext(() => token, static () => 1UL); + } + + private static WorldSession.EntityPositionUpdate PositionUpdate( + uint guid, + ushort positionSequence, + ushort teleportSequence, + ushort forcePositionSequence, + float positionX, + bool isGrounded = true) + { + return new WorldSession.EntityPositionUpdate( + guid, + new CreateObject.ServerPosition(Cell, positionX, 20f, 7f, 1f, 0f, 0f, 0f), + new Vector3(positionSequence, 2f, 3f), + PlacementId: positionSequence, + IsGrounded: isGrounded, + InstanceSequence: 1, + PositionSequence: positionSequence, + TeleportSequence: teleportSequence, + ForcePositionSequence: forcePositionSequence); + } + + private static WorldSession.EntitySpawn Spawn( + uint guid, + ushort incarnation, + bool includePosition = true, + uint? parentGuid = null, + ushort positionSequence = 1, + float positionX = 10f, + bool missile = false, + ushort teleportSequence = 0, + ushort forcePositionSequence = 0, + ushort movementSequence = 1, + ushort serverControlSequence = 1) + { + CreateObject.ServerPosition? position = includePosition + ? new CreateObject.ServerPosition(Cell, positionX, 20f, 7f, 1f, 0f, 0f, 0f) + : null; + uint rawState = (uint)(PhysicsStateFlags.Gravity + | (missile ? PhysicsStateFlags.Missile : 0)); + var timestamps = new PhysicsTimestamps( + Position: positionSequence, + Movement: movementSequence, + State: 1, + Vector: 1, + Teleport: teleportSequence, + ServerControlledMove: serverControlSequence, + ForcePosition: forcePositionSequence, + ObjDesc: 1, + Instance: incarnation); + var physics = new PhysicsSpawnData( + rawState, + position, + Movement: null, + AnimationFrame: null, + SetupTableId: null, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: parentGuid is { } parent ? new PhysicsAttachment(parent, 1u) : null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + SetupTableId: null, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: null, + Name: "initial-create", + ItemType: null, + MotionState: null, + MotionTableId: null, + PhysicsState: rawState, + InstanceSequence: incarnation, + MovementSequence: timestamps.Movement, + ServerControlSequence: timestamps.ServerControlledMove, + PositionSequence: positionSequence, + ParentGuid: parentGuid, + ParentLocation: parentGuid is null ? null : 1u, + Physics: physics); + } + + private sealed class EntityObserver(Action onEntity) + : IRuntimeEntityObjectObserver + { + public void OnEntity(in RuntimeEntityDelta delta) => onEntity(delta); + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + } +}