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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-02 03:49:56 +02:00
parent 4a8f74dc72
commit 5db3de3c7a
9 changed files with 8002 additions and 86 deletions

View file

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

View file

@ -114,6 +114,48 @@ public sealed class InboundPhysicsStateController
return false; return false;
} }
accepted = ApplyAcceptedObjDesc(old, update);
_snapshots[update.Guid] = accepted;
return true;
}
/// <summary>
/// Shared ObjDesc snapshot mutation. A retained residence continuation was
/// only ever enqueued after the exact same
/// <see cref="PhysicsTimestampGate.TryAcceptObjDescEvent"/> call already
/// succeeded at admission time, so the retained update's own
/// <see cref="ObjDescEvent.Parsed.ObjDescSequence"/> IS the stamped gate
/// value; the executor must not re-derive it from a live gate.
/// </summary>
/// <summary>
/// Instance seam for <see cref="ApplyAcceptedObjDesc"/>: reads
/// <c>_snapshots[guid]</c> as the merge base and writes the result back,
/// keeping this store and the continuation executor's canonical
/// <c>RuntimeEntityRecord.Snapshot</c> in lockstep (Round 3 A1). Without
/// this seam the executor merged directly against the record's own
/// snapshot and never touched <c>_snapshots</c>, so the FIRST later
/// legacy <c>TryApplyXxx</c> call would re-merge onto a stale base and
/// silently revert every drained continuation.
/// </summary>
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; PhysicsSpawnData? physics = old.Physics;
if (physics is { } desc) if (physics is { } desc)
physics = desc with physics = desc with
@ -121,7 +163,7 @@ public sealed class InboundPhysicsStateController
Timestamps = desc.Timestamps with { ObjDesc = update.ObjDescSequence }, Timestamps = desc.Timestamps with { ObjDesc = update.ObjDescSequence },
}; };
accepted = old with return old with
{ {
AnimPartChanges = update.ModelData.AnimPartChanges, AnimPartChanges = update.ModelData.AnimPartChanges,
TextureChanges = update.ModelData.TextureChanges, TextureChanges = update.ModelData.TextureChanges,
@ -129,8 +171,6 @@ public sealed class InboundPhysicsStateController
BasePaletteId = update.ModelData.BasePaletteId, BasePaletteId = update.ModelData.BasePaletteId,
Physics = physics, Physics = physics,
}; };
_snapshots[update.Guid] = accepted;
return true;
} }
public bool TryApplyPickup( public bool TryApplyPickup(
@ -145,11 +185,33 @@ public sealed class InboundPhysicsStateController
return false; return false;
} }
accepted = ApplyUnparentedPosition(old, null, update.PositionSequence); accepted = ApplyAcceptedPickup(old, update);
_snapshots[update.Guid] = accepted; _snapshots[update.Guid] = accepted;
return true; return true;
} }
internal static WorldSession.EntitySpawn ApplyAcceptedPickup(
WorldSession.EntitySpawn old,
PickupEvent.Parsed update) =>
ApplyUnparentedPosition(old, null, update.PositionSequence);
/// <summary>Instance seam for <see cref="ApplyAcceptedPickup"/> - see the
/// remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>.</summary>
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;
}
/// <summary> /// <summary>
/// Applies the parent branch embedded in a same-generation PhysicsDesc. /// Applies the parent branch embedded in a same-generation PhysicsDesc.
/// Unlike standalone ParentEvent it carries no parent INSTANCE_TS, so only /// Unlike standalone ParentEvent it carries no parent INSTANCE_TS, so only
@ -167,11 +229,33 @@ public sealed class InboundPhysicsStateController
return false; return false;
} }
accepted = ApplyPositionTimestampOnly(child, update.ChildPositionSequence); accepted = ApplyAcceptedCreateParent(child, update);
_snapshots[update.ChildGuid] = accepted; _snapshots[update.ChildGuid] = accepted;
return true; return true;
} }
internal static WorldSession.EntitySpawn ApplyAcceptedCreateParent(
WorldSession.EntitySpawn child,
CreateParentUpdate update) =>
ApplyPositionTimestampOnly(child, update.ChildPositionSequence);
/// <summary>Instance seam for <see cref="ApplyAcceptedCreateParent"/> -
/// see the remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>.</summary>
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( public bool TryApplyParent(
ParentEvent.Parsed update, ParentEvent.Parsed update,
out WorldSession.EntitySpawn accepted) out WorldSession.EntitySpawn accepted)
@ -186,11 +270,33 @@ public sealed class InboundPhysicsStateController
return false; return false;
} }
accepted = ApplyPositionTimestampOnly(child, update.ChildPositionSequence); accepted = ApplyAcceptedParent(child, update);
_snapshots[update.ChildGuid] = accepted; _snapshots[update.ChildGuid] = accepted;
return true; return true;
} }
internal static WorldSession.EntitySpawn ApplyAcceptedParent(
WorldSession.EntitySpawn child,
ParentEvent.Parsed update) =>
ApplyPositionTimestampOnly(child, update.ChildPositionSequence);
/// <summary>Instance seam for <see cref="ApplyAcceptedParent"/> - see the
/// remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>.</summary>
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( public bool TryCommitParent(
uint childGuid, uint childGuid,
uint parentGuid, uint parentGuid,
@ -235,17 +341,28 @@ public sealed class InboundPhysicsStateController
update.MovementSequence, update.MovementSequence,
update.ServerControlSequence); update.ServerControlSequence);
timestamps = Current(gate); 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 // Retail consumes MOVEMENT_TS before it discovers that the
// SERVER_CONTROLLED_MOVE_TS is stale. Preserve that timestamp-only // SERVER_CONTROLLED_MOVE_TS is stale (PhysicsTimestampGate.
// mutation in the canonical snapshot even though no motion payload // TryAcceptMovementEvent checks MOVEMENT_TS first and always advances
// is applied. // 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) if (!applyPayload)
{ {
accepted = default; accepted = default;
@ -258,6 +375,76 @@ public sealed class InboundPhysicsStateController
return true; return true;
} }
accepted = ApplyAcceptedMotion(
stamped,
gate.MovementTimestamp,
gate.ServerControlledMoveTimestamp,
update,
retainPayload: true);
_snapshots[update.Guid] = accepted;
return true;
}
/// <summary>
/// Shared Movement snapshot mutation. The top-level and nested
/// Movement/ServerControlledMove timestamp fields are ALWAYS stamped to
/// exactly <paramref name="movementSequence"/>/<paramref name="acceptedServerControlledMove"/>
/// (regardless of <paramref name="retainPayload"/>); 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 <c>MirrorGateTimestamps</c>
/// 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 <paramref name="update"/> 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
/// <c>MovementTimestamp</c>/<c>ServerControlledMoveTimestamp</c>
/// (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 <see cref="WorldSession.EntityMotionUpdate.MovementSequence"/>
/// 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 <c>Movement.Value.MovementSequence</c>/
/// <see cref="AcceptedPhysicsTimestamps.ServerControlledMove"/> - safe
/// there specifically because a Movement continuation is only ever
/// retained when <c>AppliesMovementPayload || HasTimestampMutation</c>,
/// 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).
/// </summary>
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; PhysicsSpawnData? physics = stamped.Physics;
if (physics is { } desc) if (physics is { } desc)
physics = desc with physics = desc with
@ -266,19 +453,37 @@ public sealed class InboundPhysicsStateController
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty,
update.MotionState, update.MotionState,
update.IsAutonomous), update.IsAutonomous),
Timestamps = desc.Timestamps with
{
Movement = gate.MovementTimestamp,
ServerControlledMove = gate.ServerControlledMoveTimestamp,
},
}; };
accepted = stamped with return stamped with
{ {
MotionState = update.MotionState, MotionState = update.MotionState,
Physics = physics, Physics = physics,
}; };
_snapshots[update.Guid] = accepted; }
/// <summary>Instance seam for <see cref="ApplyAcceptedMotion"/> - see the
/// remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>.</summary>
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; return true;
} }
@ -293,6 +498,15 @@ public sealed class InboundPhysicsStateController
return false; 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; PhysicsSpawnData? physics = old.Physics;
if (physics is { } desc) if (physics is { } desc)
physics = desc with physics = desc with
@ -302,8 +516,23 @@ public sealed class InboundPhysicsStateController
Timestamps = desc.Timestamps with { Vector = update.VectorSequence }, Timestamps = desc.Timestamps with { Vector = update.VectorSequence },
}; };
accepted = old with { Physics = physics }; return old with { Physics = physics };
_snapshots[update.Guid] = accepted; }
/// <summary>Instance seam for <see cref="ApplyAcceptedVector"/> - see the
/// remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>.</summary>
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; return true;
} }
@ -318,6 +547,15 @@ public sealed class InboundPhysicsStateController
return false; 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; PhysicsSpawnData? physics = old.Physics;
if (physics is { } desc) if (physics is { } desc)
physics = desc with physics = desc with
@ -326,12 +564,27 @@ public sealed class InboundPhysicsStateController
Timestamps = desc.Timestamps with { State = update.StateSequence }, Timestamps = desc.Timestamps with { State = update.StateSequence },
}; };
accepted = old with return old with
{ {
PhysicsState = update.PhysicsState, PhysicsState = update.PhysicsState,
Physics = physics, Physics = physics,
}; };
_snapshots[update.Guid] = accepted; }
/// <summary>Instance seam for <see cref="ApplyAcceptedState"/> - see the
/// remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>.</summary>
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; return true;
} }
@ -339,6 +592,20 @@ public sealed class InboundPhysicsStateController
/// Returns true when the addressed live incarnation exists, even when the /// Returns true when the addressed live incarnation exists, even when the
/// position payload is rejected. This lets callers publish a freshly /// position payload is rejected. This lets callers publish a freshly
/// consumed FORCE_POSITION_TS without applying a stale pose. /// 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 <see cref="PositionTimestampDisposition"/> alone. The
/// continuation executor's <c>ApplyPositionAction</c> instead runs
/// <c>RuntimeAuthoritativePositionRouteClassifier</c> and derives
/// contact solely from the retained wire packet's own
/// <c>IsGrounded</c> bit. This is internal refactor debt tracked for
/// the eventual cutover unification (this file's <c>TryApplyPosition</c>
/// 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.
/// </summary> /// </summary>
public bool TryApplyPosition( public bool TryApplyPosition(
WorldSession.EntityPositionUpdate update, WorldSession.EntityPositionUpdate update,
@ -370,12 +637,153 @@ public sealed class InboundPhysicsStateController
gate, gate,
teleportAdvanced: disposition is PositionTimestampDisposition.Apply teleportAdvanced: disposition is PositionTimestampDisposition.Apply
&& advancesTeleport); && 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;
}
/// <summary>Instance seam for <see cref="ApplyAcceptedPosition"/> - see
/// the remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>. The
/// executor passes its classified route's own
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>
/// flags rather than the legacy path's unconditional true/true.</summary>
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); accepted = default;
_snapshots[update.Guid] = accepted; return false;
return true;
} }
accepted = ApplyAcceptedPosition(
old,
update,
disposition,
timestamps,
isLocalPlayer,
forcePositionRotation,
currentLocalVelocity,
installPlacementFrame,
clearParent);
_snapshots[guid] = accepted;
return true;
}
/// <summary>
/// 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 <see cref="ApplyAcceptedPositionTimestampOnly"/>, 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.
/// </summary>
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;
}
/// <summary>
/// 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
/// <c>SmartBox::HandleReceivedPosition</c> (0x00453FD0) /
/// <c>PositionPack::UnPack</c> (0x00516740) exactly as the legacy
/// immediate-apply path did.
///
/// A <see cref="PositionTimestampDisposition.Rejected"/> retained
/// continuation only ever exists because
/// <see cref="AcceptedPhysicsTimestamps.TeleportHookRequired"/>-adjacent
/// bookkeeping mutated (see <see cref="PhysicsTimestampGate.TryAcceptPositionEvent"/>:
/// 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
/// <see cref="WorldSession.EntityPositionUpdate.PositionSequence"/> 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
/// <see cref="AcceptedPhysicsTimestamps"/> captured at admission time.
///
/// <paramref name="installPlacementFrame"/>/<paramref name="clearParent"/>
/// (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
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c> -
/// both false only for the FORCE_POSITION branch, which retail's
/// MoveOrTeleport returns from immediately, BEFORE either call.
/// </summary>
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; CreateObject.ServerPosition appliedPosition = update.Position;
if (disposition is PositionTimestampDisposition.ForcePosition if (disposition is PositionTimestampDisposition.ForcePosition
@ -392,9 +800,14 @@ public sealed class InboundPhysicsStateController
// PositionPack::UnPack (0x00516740) initializes an absent placement // PositionPack::UnPack (0x00516740) initializes an absent placement
// id to zero; HandleReceivedPosition (0x00453FD0) forwards that exact // id to zero; HandleReceivedPosition (0x00453FD0) forwards that exact
// value to SetPlacementFrame on a normal accepted update. // value to SetPlacementFrame on a normal accepted update - but only
uint? appliedPlacement = disposition is PositionTimestampDisposition.Apply // when the caller's route actually runs that step
? update.PlacementId ?? 0u // (installPlacementFrame; retail skips it entirely while HasAnimations
// is true).
uint? appliedPlacement = installPlacementFrame
? (disposition is PositionTimestampDisposition.Apply
? update.PlacementId ?? 0u
: old.PlacementId)
: old.PlacementId; : old.PlacementId;
System.Numerics.Vector3? appliedVelocity = disposition switch System.Numerics.Vector3? appliedVelocity = disposition switch
@ -407,7 +820,7 @@ public sealed class InboundPhysicsStateController
// A fresh local teleport explicitly installs zero velocity. A // A fresh local teleport explicitly installs zero velocity. A
// normal local correction does not consume PositionPack velocity. // normal local correction does not consume PositionPack velocity.
PositionTimestampDisposition.Apply when isLocalPlayer => PositionTimestampDisposition.Apply when isLocalPlayer =>
advancesTeleport timestamps.TeleportAdvanced
? System.Numerics.Vector3.Zero ? System.Numerics.Vector3.Zero
: currentLocalVelocity ?? old.Physics?.Velocity, : currentLocalVelocity ?? old.Physics?.Velocity,
@ -418,6 +831,22 @@ public sealed class InboundPhysicsStateController
_ => old.Physics?.Velocity, _ => 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; PhysicsSpawnData? physics = old.Physics;
if (physics is { } desc) if (physics is { } desc)
physics = desc with physics = desc with
@ -425,26 +854,47 @@ public sealed class InboundPhysicsStateController
Position = appliedPosition, Position = appliedPosition,
AnimationFrame = appliedPlacement, AnimationFrame = appliedPlacement,
Velocity = appliedVelocity, Velocity = appliedVelocity,
Parent = null, Parent = physicsParent,
Timestamps = desc.Timestamps with Timestamps = desc.Timestamps with
{ {
Position = gate.PositionTimestamp, Position = update.PositionSequence,
Teleport = gate.TeleportTimestamp, Teleport = timestamps.Teleport,
ForcePosition = gate.ForcePositionTimestamp, ForcePosition = timestamps.ForcePosition,
}, },
}; };
accepted = old with return old with
{ {
Position = appliedPosition, Position = appliedPosition,
PositionSequence = gate.PositionTimestamp, PositionSequence = update.PositionSequence,
ParentGuid = null, ParentGuid = parentGuid,
ParentLocation = null, ParentLocation = parentLocation,
PlacementId = appliedPlacement, PlacementId = appliedPlacement,
Physics = physics, Physics = physics,
}; };
_snapshots[update.Guid] = accepted; }
return true;
/// <summary>
/// The Rejected-disposition-but-mutated branch of
/// <see cref="ApplyAcceptedPosition"/>: 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.
/// </summary>
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 };
} }
/// <summary> /// <summary>
@ -675,31 +1125,6 @@ public sealed class InboundPhysicsStateController
TeleportHookRequired: false, TeleportHookRequired: false,
previousTeleport); 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( private static WorldSession.EntitySpawn MergeUntimestampedCreate(
WorldSession.EntitySpawn retained, WorldSession.EntitySpawn retained,
WorldSession.EntitySpawn incoming) => WorldSession.EntitySpawn incoming) =>
@ -727,6 +1152,32 @@ public sealed class InboundPhysicsStateController
Physics = retained.Physics, Physics = retained.Physics,
}; };
/// <summary>
/// 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
/// (<see cref="MergeUntimestampedCreate"/>), keeping the retained
/// Position/appearance/physics-timestamp fields that earlier stages in
/// THIS envelope (and any earlier FIFO entry) already committed to
/// <c>_snapshots</c>, and taking only the incoming packet's untimestamped
/// identity/description fields.
/// </summary>
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( private static SameGenerationCreateObjectEvents BuildSameGenerationEvents(
WorldSession.EntitySpawn incoming) WorldSession.EntitySpawn incoming)
{ {

View file

@ -1,3 +1,4 @@
using System.Collections.Immutable;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
@ -20,8 +21,53 @@ public sealed class ParentAttachmentState
private readonly Dictionary<ParentIncarnation, List<uint>> _committedChildrenByParent = new(); private readonly Dictionary<ParentIncarnation, List<uint>> _committedChildrenByParent = new();
private readonly Dictionary<uint, Queue<DeferredParentCreate>> private readonly Dictionary<uint, Queue<DeferredParentCreate>>
_deferredCreatesByParent = []; _deferredCreatesByParent = [];
/// <summary>
/// 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 <see cref="_deferredCreatesByParent"/> - retail's
/// <c>QueueBlobForObject</c>/<c>CObjectMaint</c> bucket does not
/// distinguish a raw Create blob from any other blob type queued
/// against the same guid.
/// </summary>
private readonly Dictionary<uint, Queue<DeferredAcceptedParentRelation>>
_deferredAcceptedRelationsByParent = [];
private ulong _nextDeferredCreateAdmissionId; private ulong _nextDeferredCreateAdmissionId;
/// <summary>
/// Round 5 R5-2: cancellation-aware detach/restore window state, shared
/// by BOTH deferred buckets. <see cref="DetachDeferredCreates"/> and
/// <see cref="DetachDeferredAcceptedRelations"/> register one window
/// entry per detach; while it is open, every cancellation primitive
/// (<see cref="CancelDeferredChildGeneration"/>, <see cref="EndGeneration"/>,
/// <see cref="DeleteGeneration"/>, <see cref="RemoveObject"/>,
/// <see cref="RemoveChild"/>) 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. <see cref="Clear"/> 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
/// <see cref="ConsumeDeferredCreate"/> already relies on.
/// </summary>
private sealed class CreateWindowState
{
internal required uint ParentGuid { get; init; }
internal List<Func<DeferredParentCreate, bool>> Filters { get; } = [];
}
private sealed class RelationWindowState
{
internal required uint ParentGuid { get; init; }
internal List<Func<DeferredAcceptedParentRelation, bool>> Filters { get; } = [];
}
private readonly Dictionary<ulong, CreateWindowState> _createWindows = [];
private readonly Dictionary<ulong, RelationWindowState> _relationWindows = [];
private ulong _nextWindowId;
public int UnresolvedRelationCount => public int UnresolvedRelationCount =>
_unresolvedByChild.Values.Sum(queue => queue.Count); _unresolvedByChild.Values.Sum(queue => queue.Count);
public int StagedRelationCount => _stagedByChild.Count; public int StagedRelationCount => _stagedByChild.Count;
@ -29,6 +75,8 @@ public sealed class ParentAttachmentState
public int CommittedRelationCount => _lastAcceptedByChild.Count; public int CommittedRelationCount => _lastAcceptedByChild.Count;
internal int DeferredCreateCount => internal int DeferredCreateCount =>
_deferredCreatesByParent.Values.Sum(queue => queue.Count); _deferredCreatesByParent.Values.Sum(queue => queue.Count);
internal int DeferredAcceptedRelationCount =>
_deferredAcceptedRelationsByParent.Values.Sum(queue => queue.Count);
/// <summary> /// <summary>
/// Retains the complete unaccepted CreateObject packet when its nonzero /// Retains the complete unaccepted CreateObject packet when its nonzero
@ -102,6 +150,81 @@ public sealed class ParentAttachmentState
return true; return true;
} }
/// <summary>
/// Round 3 B7: retail's <c>PartArray::add_child</c>-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
/// (<paramref name="window"/>) for the detached batch - see the window-
/// machinery remarks at this class's field declarations.
/// </summary>
internal ImmutableArray<DeferredParentCreate> DetachDeferredCreates(
uint parentGuid,
out DeferredReplayWindowToken window)
{
if (!_deferredCreatesByParent.Remove(
parentGuid,
out Queue<DeferredParentCreate>? queue))
{
window = default;
return ImmutableArray<DeferredParentCreate>.Empty;
}
ulong id = ++_nextWindowId;
_createWindows[id] = new CreateWindowState { ParentGuid = parentGuid };
window = new DeferredReplayWindowToken(id, parentGuid, DeferredReplayBucketKind.Creates);
return [.. queue];
}
/// <summary>
/// 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 <see cref="DeferredParentCreate.AdmissionId"/> 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).
/// </summary>
internal void RestoreDeferredCreates(
in DeferredReplayWindowToken window,
ReadOnlySpan<DeferredParentCreate> entries)
{
if (window.Kind != DeferredReplayBucketKind.Creates
|| !_createWindows.Remove(window.Id, out CreateWindowState? state))
{
return;
}
if (entries.Length == 0)
return;
IEnumerable<DeferredParentCreate> filtered = entries.ToArray();
foreach (Func<DeferredParentCreate, bool> filter in state.Filters)
filtered = filtered.Where(filter);
DeferredParentCreate[] survivors = filtered.ToArray();
if (survivors.Length == 0)
return;
var restored = new Queue<DeferredParentCreate>(survivors.Length);
foreach (DeferredParentCreate entry in survivors)
restored.Enqueue(entry);
if (_deferredCreatesByParent.TryGetValue(
window.ParentGuid,
out Queue<DeferredParentCreate>? existing))
{
foreach (DeferredParentCreate entry in existing)
restored.Enqueue(entry);
}
_deferredCreatesByParent[window.ParentGuid] = restored;
}
internal bool ContainsDeferredCreate( internal bool ContainsDeferredCreate(
uint childGuid, uint childGuid,
ushort instanceSequence) ushort instanceSequence)
@ -119,18 +242,151 @@ public sealed class ParentAttachmentState
return false; return false;
} }
/// <summary>
/// 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 -&gt;
/// <c>QueueBlobForObject</c>, pseudo-C 92326; GUID-keyed placeholder
/// bucket in <c>CObjectMaint</c>, 271082-271088) and replays it when
/// that guid is created, exactly like a raw missing-parent Create.
/// Shares the SAME monotonic AdmissionId source as
/// <see cref="EnqueueDeferredCreate"/> (never reset) - both buckets are
/// "blobs waiting on guid X," the same general retail mechanism.
/// </summary>
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;
}
/// <summary>
/// Re-enqueues an EXISTING relation verbatim, preserving its original
/// <see cref="DeferredAcceptedParentRelation.AdmissionId"/> - used at
/// replay time when the relation still names a parent incarnation that
/// has not yet arrived (wait for the next matching incarnation).
/// </summary>
internal void EnqueueDeferredAcceptedRelation(
in DeferredAcceptedParentRelation relation)
{
uint parentGuid = relation.Standalone?.ParentGuid
?? relation.Envelope?.ParentGuid
?? 0u;
if (!_deferredAcceptedRelationsByParent.TryGetValue(
parentGuid,
out Queue<DeferredAcceptedParentRelation>? queue))
{
queue = new Queue<DeferredAcceptedParentRelation>();
_deferredAcceptedRelationsByParent.Add(parentGuid, queue);
}
queue.Enqueue(relation);
}
/// <summary>Round 5 R5-2 window-aware detach - see <see cref="DetachDeferredCreates"/>'s remarks.</summary>
internal ImmutableArray<DeferredAcceptedParentRelation> DetachDeferredAcceptedRelations(
uint parentGuid,
out DeferredReplayWindowToken window)
{
if (!_deferredAcceptedRelationsByParent.Remove(
parentGuid,
out Queue<DeferredAcceptedParentRelation>? queue))
{
window = default;
return ImmutableArray<DeferredAcceptedParentRelation>.Empty;
}
ulong id = ++_nextWindowId;
_relationWindows[id] = new RelationWindowState { ParentGuid = parentGuid };
window = new DeferredReplayWindowToken(id, parentGuid, DeferredReplayBucketKind.AcceptedRelations);
return [.. queue];
}
/// <summary>Round 5 R5-2 window-aware restore - see <see cref="RestoreDeferredCreates"/>'s remarks.</summary>
internal void RestoreDeferredAcceptedRelations(
in DeferredReplayWindowToken window,
ReadOnlySpan<DeferredAcceptedParentRelation> entries)
{
if (window.Kind != DeferredReplayBucketKind.AcceptedRelations
|| !_relationWindows.Remove(window.Id, out RelationWindowState? state))
{
return;
}
if (entries.Length == 0)
return;
IEnumerable<DeferredAcceptedParentRelation> filtered = entries.ToArray();
foreach (Func<DeferredAcceptedParentRelation, bool> filter in state.Filters)
filtered = filtered.Where(filter);
DeferredAcceptedParentRelation[] survivors = filtered.ToArray();
if (survivors.Length == 0)
return;
var restored = new Queue<DeferredAcceptedParentRelation>(survivors.Length);
foreach (DeferredAcceptedParentRelation entry in survivors)
restored.Enqueue(entry);
if (_deferredAcceptedRelationsByParent.TryGetValue(
window.ParentGuid,
out Queue<DeferredAcceptedParentRelation>? existing))
{
foreach (DeferredAcceptedParentRelation entry in existing)
restored.Enqueue(entry);
}
_deferredAcceptedRelationsByParent[window.ParentGuid] = restored;
}
internal bool ContainsDeferredAcceptedRelation(
uint childGuid,
RuntimeEntityKey childKey)
{
foreach (Queue<DeferredAcceptedParentRelation> queue
in _deferredAcceptedRelationsByParent.Values)
{
if (queue.Any(candidate =>
candidate.ChildGuid == childGuid
&& candidate.ChildKey == childKey))
{
return true;
}
}
return false;
}
/// <summary> /// <summary>
/// Cancels only the raw, still-unaccepted child generation addressed by a /// Cancels only the raw, still-unaccepted child generation addressed by a
/// terminal packet. Instance zero is a normal retail timestamp and is not /// 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.
/// </summary> /// </summary>
internal void CancelDeferredChildGeneration( internal void CancelDeferredChildGeneration(
uint childGuid, uint childGuid,
ushort terminalInstanceSequence) => FilterDeferredCreates( ushort terminalInstanceSequence)
{
FilterDeferredCreates(
candidate => candidate.Spawn.Guid != childGuid candidate => candidate.Spawn.Guid != childGuid
|| PhysicsTimestampGate.IsNewer( || PhysicsTimestampGate.IsNewer(
terminalInstanceSequence, terminalInstanceSequence,
candidate.Spawn.InstanceSequence)); candidate.Spawn.InstanceSequence));
FilterDeferredAcceptedRelations(
candidate => candidate.ChildGuid != childGuid
|| PhysicsTimestampGate.IsNewer(
terminalInstanceSequence,
candidate.ChildKey.Incarnation));
}
public void AcceptCreateObjectRelation(ParentAttachmentRelation relation) public void AcceptCreateObjectRelation(ParentAttachmentRelation relation)
{ {
@ -379,6 +635,7 @@ public sealed class ParentAttachmentState
public void RemoveObject(uint guid) public void RemoveObject(uint guid)
{ {
RemoveDeferredChildCreates(guid); RemoveDeferredChildCreates(guid);
RemoveDeferredAcceptedRelationsForChild(guid);
_stagedByChild.Remove(guid); _stagedByChild.Remove(guid);
_recoveryByChild.Remove(guid); _recoveryByChild.Remove(guid);
RemoveCommittedChild(guid); RemoveCommittedChild(guid);
@ -413,6 +670,12 @@ public sealed class ParentAttachmentState
|| PhysicsTimestampGate.IsNewer( || PhysicsTimestampGate.IsNewer(
replacementGeneration, replacementGeneration,
candidate.Spawn.InstanceSequence)); candidate.Spawn.InstanceSequence));
FilterDeferredAcceptedRelations(candidate =>
candidate.ChildGuid != guid
|| candidate.ChildKey.Incarnation == replacementGeneration
|| PhysicsTimestampGate.IsNewer(
replacementGeneration,
candidate.ChildKey.Incarnation));
FilterChildCandidates( FilterChildCandidates(
guid, guid,
relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent); relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent);
@ -468,6 +731,7 @@ public sealed class ParentAttachmentState
public void RemoveChild(uint childGuid) public void RemoveChild(uint childGuid)
{ {
RemoveDeferredChildCreates(childGuid); RemoveDeferredChildCreates(childGuid);
RemoveDeferredAcceptedRelationsForChild(childGuid);
_stagedByChild.Remove(childGuid); _stagedByChild.Remove(childGuid);
_recoveryByChild.Remove(childGuid); _recoveryByChild.Remove(childGuid);
RemoveCommittedChild(childGuid); RemoveCommittedChild(childGuid);
@ -477,6 +741,12 @@ public sealed class ParentAttachmentState
public void Clear() public void Clear()
{ {
_deferredCreatesByParent.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(); _unresolvedByChild.Clear();
_stagedByChild.Clear(); _stagedByChild.Clear();
_recoveryByChild.Clear(); _recoveryByChild.Clear();
@ -490,6 +760,15 @@ public sealed class ParentAttachmentState
=> FilterDeferredCreates( => FilterDeferredCreates(
candidate => candidate.Spawn.Guid != childGuid); candidate => candidate.Spawn.Guid != childGuid);
private void RemoveDeferredAcceptedRelationsForChild(uint childGuid)
=> FilterDeferredAcceptedRelations(
candidate => candidate.ChildGuid != childGuid);
/// <summary>
/// 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.
/// </summary>
private void FilterDeferredCreates( private void FilterDeferredCreates(
Func<DeferredParentCreate, bool> retain) Func<DeferredParentCreate, bool> retain)
{ {
@ -504,6 +783,27 @@ public sealed class ParentAttachmentState
else else
_deferredCreatesByParent[parentGuid] = retained; _deferredCreatesByParent[parentGuid] = retained;
} }
foreach (CreateWindowState state in _createWindows.Values)
state.Filters.Add(retain);
}
/// <summary>Round 5 R5-2 relation-bucket counterpart of <see cref="FilterDeferredCreates"/>.</summary>
private void FilterDeferredAcceptedRelations(
Func<DeferredAcceptedParentRelation, bool> retain)
{
uint[] parents = _deferredAcceptedRelationsByParent.Keys.ToArray();
for (int index = 0; index < parents.Length; index++)
{
uint parentGuid = parents[index];
Queue<DeferredAcceptedParentRelation> 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) private void RemoveCommittedChild(uint childGuid)
@ -609,6 +909,52 @@ internal readonly record struct DeferredParentCreate(
&& Spawn.Guid != 0u; && Spawn.Guid != 0u;
} }
/// <summary>
/// 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
/// <see cref="Standalone"/> (a standalone Parent continuation, which HAS a
/// parent incarnation to compare) or <see cref="Envelope"/> (an envelope
/// CreateParent stage, which does not).
/// </summary>
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);
/// <summary>Null for the envelope flavor - <see cref="CreateParentUpdate"/> carries no parent INSTANCE_TS.</summary>
internal ushort? ParentInstanceSequence => Standalone?.ParentInstanceSequence;
}
/// <summary>Round 5 R5-2: which deferred bucket a <see cref="DeferredReplayWindowToken"/> belongs to.</summary>
internal enum DeferredReplayBucketKind : byte
{
Creates,
AcceptedRelations,
}
/// <summary>
/// Round 5 R5-2: opaque handle for one open detach/restore window. See the
/// window-machinery remarks at <see cref="ParentAttachmentState"/>'s field
/// declarations for the full cancellation-awareness contract.
/// </summary>
internal readonly record struct DeferredReplayWindowToken(
ulong Id,
uint ParentGuid,
DeferredReplayBucketKind Kind)
{
internal bool IsValid => Id != 0UL;
}
public readonly record struct ParentAttachmentRelation( public readonly record struct ParentAttachmentRelation(
uint ParentGuid, uint ParentGuid,
uint ChildGuid, uint ChildGuid,

View file

@ -547,6 +547,101 @@ public sealed class RuntimeEntityDirectory
public bool IsFreshTeleportStart(uint guid, ushort teleportSequence) => public bool IsFreshTeleportStart(uint guid, ushort teleportSequence) =>
_inbound.IsFreshTeleportStart(guid, 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) private bool IsKnown(RuntimeEntityRecord record)
{ {
if (IsCurrent(record)) if (IsCurrent(record))

View file

@ -31,6 +31,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
int EquipmentOwnerCount, int EquipmentOwnerCount,
int PendingMoveCount, int PendingMoveCount,
int InitialCreateResidenceLeaseCount, int InitialCreateResidenceLeaseCount,
int InitialCreateExecutorProgressCount,
int StreamSubscriberCount, int StreamSubscriberCount,
int PlacementStreamSubscriberCount, int PlacementStreamSubscriberCount,
long StreamDispatchFailureCount, long StreamDispatchFailureCount,
@ -38,7 +39,12 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
int PendingDispatchCount, int PendingDispatchCount,
bool IsDispatching, bool IsDispatching,
bool IsSessionClearInProgress, bool IsSessionClearInProgress,
bool IsDisposed) bool IsDisposed,
/// <summary>Round 5 R5-1: pending queue-by-parent-GUID accepted relations (see <see cref="ParentAttachmentState.DeferredAcceptedRelationCount"/>).</summary>
int DeferredAcceptedRelationCount = 0,
/// <summary>Round 5 R5-3: mirrors StreamDispatchFailureCount/HasLastStreamDispatchFailure for the executor's contained-replay failure surface. Diagnostic only - like its stream precedent, NOT gated by <see cref="IsConverged"/>.</summary>
long ReplayFailureCount = 0,
bool HasLastReplayFailure = false)
{ {
public bool IsConverged => public bool IsConverged =>
IsDisposed IsDisposed
@ -48,6 +54,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
&& AcceptedSnapshotCount == 0 && AcceptedSnapshotCount == 0
&& UnresolvedParentRelationCount == 0 && UnresolvedParentRelationCount == 0
&& DeferredParentCreateCount == 0 && DeferredParentCreateCount == 0
&& DeferredAcceptedRelationCount == 0
&& StagedParentRelationCount == 0 && StagedParentRelationCount == 0
&& RecoveryParentRelationCount == 0 && RecoveryParentRelationCount == 0
&& CommittedParentRelationCount == 0 && CommittedParentRelationCount == 0
@ -57,6 +64,7 @@ public readonly record struct RuntimeEntityObjectOwnershipSnapshot(
&& EquipmentOwnerCount == 0 && EquipmentOwnerCount == 0
&& PendingMoveCount == 0 && PendingMoveCount == 0
&& InitialCreateResidenceLeaseCount == 0 && InitialCreateResidenceLeaseCount == 0
&& InitialCreateExecutorProgressCount == 0
&& StreamSubscriberCount == 0 && StreamSubscriberCount == 0
&& PlacementStreamSubscriberCount == 0 && PlacementStreamSubscriberCount == 0
&& PendingDispatchCount == 0 && PendingDispatchCount == 0
@ -127,6 +135,23 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
InitialCreateResidences = new RuntimeInitialCreateResidenceState( InitialCreateResidences = new RuntimeInitialCreateResidenceState(
Entities, Entities,
Physics.SetPosition); 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( Placements = new RuntimePlacementProjectionChannel(
Events, Events,
Physics.SetPosition); Physics.SetPosition);
@ -155,6 +180,23 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
InitialCreateResidences = new RuntimeInitialCreateResidenceState( InitialCreateResidences = new RuntimeInitialCreateResidenceState(
Entities, Entities,
Physics.SetPosition); 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( Placements = new RuntimePlacementProjectionChannel(
Events, Events,
Physics.SetPosition); Physics.SetPosition);
@ -183,6 +225,23 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
InitialCreateResidences = new RuntimeInitialCreateResidenceState( InitialCreateResidences = new RuntimeInitialCreateResidenceState(
Entities, Entities,
Physics.SetPosition); 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( Placements = new RuntimePlacementProjectionChannel(
Events, Events,
Physics.SetPosition); Physics.SetPosition);
@ -197,6 +256,8 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
public RuntimePlacementProjectionChannel Placements { get; } public RuntimePlacementProjectionChannel Placements { get; }
internal RuntimeInitialCreateResidenceState InitialCreateResidences internal RuntimeInitialCreateResidenceState InitialCreateResidences
{ get; } { get; }
internal RuntimeInitialCreateContinuationExecutor InitialCreateExecution
{ get; }
public RuntimeEntityObjectOwnershipSnapshot CaptureOwnership() public RuntimeEntityObjectOwnershipSnapshot CaptureOwnership()
{ {
@ -220,6 +281,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Objects.PendingMoveCount, Objects.PendingMoveCount,
initialResidence.ActiveLeaseCount initialResidence.ActiveLeaseCount
+ initialResidence.PendingAdoptionCount, + initialResidence.PendingAdoptionCount,
InitialCreateExecution.ProgressCount,
Events.SubscriberCount, Events.SubscriberCount,
Events.PlacementSubscriberCount, Events.PlacementSubscriberCount,
Events.DispatchFailureCount, Events.DispatchFailureCount,
@ -227,7 +289,10 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Events.PendingDispatchCount, Events.PendingDispatchCount,
Events.IsDispatching, Events.IsDispatching,
_sessionClearInProgress, _sessionClearInProgress,
_disposed); _disposed,
parents.DeferredAcceptedRelationCount,
InitialCreateExecution.ReplayFailureCount,
InitialCreateExecution.LastReplayFailure is not null);
} }
public void BindEventContext( public void BindEventContext(
@ -238,6 +303,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
Events.BindContext(generation, frameNumber); Events.BindContext(generation, frameNumber);
Placements.BindGeneration(generation); Placements.BindGeneration(generation);
InitialCreateResidences.BindGeneration(generation); InitialCreateResidences.BindGeneration(generation);
InitialCreateExecution.BindGeneration(generation);
} }
/// <summary> /// <summary>
@ -1515,6 +1581,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
_sessionClearInProgress = true; _sessionClearInProgress = true;
RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray(); RuntimeEntityRecord[] active = Entities.ActiveRecords.ToArray();
InitialCreateResidences.Clear(); InitialCreateResidences.Clear();
InitialCreateExecution.DiscardAll();
Physics.CollisionReports.LeaveWorldBatch(active); Physics.CollisionReports.LeaveWorldBatch(active);
Physics.ResetSessionPhysics(); Physics.ResetSessionPhysics();
Entities.BeginSessionClear(); Entities.BeginSessionClear();
@ -2028,12 +2095,20 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
private RuntimePlacementCancellationReceipt ForgetInitialCreateResidence( private RuntimePlacementCancellationReceipt ForgetInitialCreateResidence(
RuntimeEntityRecord canonical) RuntimeEntityRecord canonical)
{ {
return InitialCreateResidences.Forget( bool forgotten = InitialCreateResidences.Forget(
canonical, canonical,
out _, out _,
out RuntimePlacementCancellationReceipt cancellation) out RuntimePlacementCancellationReceipt cancellation);
? cancellation // Round 3 B3: InitialCreateResidences.Forget's own retirement
: default; // 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( private static RuntimePlacementCancellationReceipt PreferCancellation(

File diff suppressed because it is too large Load diff

View file

@ -362,6 +362,20 @@ internal enum RuntimeInitialCreateResidenceCompletionStatus : byte
RejectedAuthority, RejectedAuthority,
} }
/// <summary>
/// Result of <see cref="RuntimeInitialCreateResidenceState.ConsumeExecuted"/>,
/// the executor-only release that supersedes the host's
/// <see cref="RuntimeInitialCreateResidenceState.AcknowledgeAdoption"/> once
/// the initial placement has been adopted.
/// </summary>
internal enum RuntimeInitialCreateResidenceExecutorReleaseStatus : byte
{
Released,
Revised,
RejectedToken,
RejectedAuthority,
}
/// <summary> /// <summary>
/// Exact post-residence receipt. A local graphical or no-window host may run /// Exact post-residence receipt. A local graphical or no-window host may run
/// the retail after-enter teleport suffix only when this receipt carries /// the retail after-enter teleport suffix only when this receipt carries
@ -385,6 +399,29 @@ internal readonly record struct RuntimeInitialCreateResidenceOwnershipSnapshot(
&& PendingAdoptionCount == 0; && PendingAdoptionCount == 0;
} }
/// <summary>
/// Round 4 R4-4: field-masked precision for
/// <see cref="RuntimeInitialCreateResidenceState.AdvanceExecutorBaseline"/>.
/// 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 <see cref="RuntimeInitialCreateResidenceState.IsCompletedCurrent"/>
/// check catch it. Each caller now passes exactly the field(s) its OWN
/// mutation moved.
/// </summary>
[Flags]
internal enum RuntimeExecutorBaselineFields : byte
{
None = 0,
PositionAuthorityVersion = 1 << 0,
CreateIntegrationVersion = 1 << 1,
FullCellId = 1 << 2,
PlacementCommitVersion = 1 << 3,
}
/// <summary> /// <summary>
/// Owns only initial CreateObject residence leases. DAT lookup, body creation, /// Owns only initial CreateObject residence leases. DAT lookup, body creation,
/// and presentation stay outside this owner; their immutable preparation is /// 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 RuntimeEntityRecord Record { get; init; }
internal required RuntimeInitialCreateResidenceLease Lease { get; set; } internal required RuntimeInitialCreateResidenceLease Lease { get; set; }
internal required RuntimeInitialCreateResidenceReceipt Receipt { get; set; } internal required RuntimeInitialCreateResidenceReceipt Receipt { get; set; }
/// <summary>
/// True once the continuation executor has consumed the initial
/// placement's acknowledged completion through
/// <see cref="AdoptCompletedPlacement"/>. A retained
/// <c>_acknowledgedPlacementCompletions</c> entry on
/// <see cref="RuntimeSetPositionState"/> blocks EVERY later placement
/// begin for the same key (see
/// <see cref="RuntimeSetPositionState.BeginAcceptedPlacementCore"/>'s
/// <c>HasRetainedCompletion</c> 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.
/// </summary>
internal bool PlacementAdopted { get; set; }
/// <summary>
/// Executor-tracked baseline for the four version/cell fields
/// <see cref="IsCompletedCurrent"/> compares against the LIVE record.
/// Seeded from <see cref="Receipt"/>'s own (frozen, identity-matching)
/// <c>Token</c>/<c>FullCellId</c>/<c>PlacementCommitVersion</c> at the
/// moment <see cref="Complete"/> first produces this entry, then kept
/// in sync by <see cref="AdvanceExecutorBaseline"/> every time the
/// continuation executor legitimately advances one of them while
/// applying a retained continuation. <see cref="Receipt"/>.Token
/// itself must NEVER be rebaselined — a caller (the executor) always
/// re-presents the SAME original token instance on every retry, and
/// <see cref="Complete"/>'s own token-identity match
/// (<c>completed.Receipt.Token == token</c>) 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.
/// </summary>
internal ulong ExpectedPositionAuthorityVersion { get; set; }
internal ulong ExpectedCreateIntegrationVersion { get; set; }
internal uint ExpectedFullCellId { get; set; }
internal ulong ExpectedPlacementCommitVersion { get; set; }
} }
private readonly RuntimeEntityDirectory _entities; private readonly RuntimeEntityDirectory _entities;
@ -410,6 +489,7 @@ internal sealed class RuntimeInitialCreateResidenceState
private readonly Dictionary<RuntimeEntityKey, Entry> _entries = []; private readonly Dictionary<RuntimeEntityKey, Entry> _entries = [];
private readonly Dictionary<RuntimeEntityKey, CompletedEntry> _completed = []; private readonly Dictionary<RuntimeEntityKey, CompletedEntry> _completed = [];
private Func<RuntimeGenerationToken>? _generation; private Func<RuntimeGenerationToken>? _generation;
private Action<RuntimeEntityKey>? _retirementNotification;
private ulong _nextLeaseId; private ulong _nextLeaseId;
internal RuntimeInitialCreateResidenceState( internal RuntimeInitialCreateResidenceState(
@ -432,6 +512,31 @@ internal sealed class RuntimeInitialCreateResidenceState
_generation = generation; _generation = generation;
} }
/// <summary>
/// Round 3 B3: the ONE choke point every residence retirement path -
/// <see cref="Retire(Entry)"/>, <see cref="Retire(CompletedEntry)"/>,
/// <see cref="Forget"/>, and <see cref="Clear"/> - 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 <c>DiscardProgress</c> call (e.g. a host's
/// <see cref="TryGetTransaction"/> 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.
/// </summary>
internal void BindRetirementNotification(Action<RuntimeEntityKey> 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) internal bool CanAcceptCreate(WorldSession.EntitySpawn incoming)
{ {
bool parented = (incoming.ParentGuid bool parented = (incoming.ParentGuid
@ -788,10 +893,59 @@ internal sealed class RuntimeInitialCreateResidenceState
Record = record, Record = record,
Lease = lease, Lease = lease,
Receipt = receipt, Receipt = receipt,
ExpectedPositionAuthorityVersion = token.PositionAuthorityVersion,
ExpectedCreateIntegrationVersion = token.CreateIntegrationVersion,
ExpectedFullCellId = receipt.FullCellId,
ExpectedPlacementCommitVersion = receipt.PlacementCommitVersion,
}); });
return RuntimeInitialCreateResidenceCompletionStatus.Completed; return RuntimeInitialCreateResidenceCompletionStatus.Completed;
} }
/// <summary>
/// Executor-only: re-synchronizes the completed entry's staleness
/// baseline (see <see cref="CompletedEntry.ExpectedPositionAuthorityVersion"/>
/// remarks) to the record's CURRENT live values, but ONLY for the
/// field(s) named in <paramref name="fields"/> (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 <see cref="Complete"/>/<see cref="IsCompletedCurrent"/> 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.
/// </summary>
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( internal bool AcknowledgeAdoption(
RuntimeEntityRecord record, RuntimeEntityRecord record,
in RuntimeInitialCreateResidenceAdoptionToken token) in RuntimeInitialCreateResidenceAdoptionToken token)
@ -816,7 +970,15 @@ internal sealed class RuntimeInitialCreateResidenceState
// discard accepted packets. // discard accepted packets.
if (!current.Lease.Continuations.IsEmpty) if (!current.Lease.Continuations.IsEmpty)
return false; 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 if (current.Lease.Route.PerformsSetPosition
&& !current.PlacementAdopted
&& !_setPosition.ConsumeAcknowledgedPlacement( && !_setPosition.ConsumeAcknowledgedPlacement(
current.Lease.Placement, current.Lease.Placement,
current.Receipt.Projection)) current.Receipt.Projection))
@ -841,6 +1003,7 @@ internal sealed class RuntimeInitialCreateResidenceState
lease = entry.Lease; lease = entry.Lease;
cancellation = _setPosition.ForgetExactPlacement( cancellation = _setPosition.ForgetExactPlacement(
lease.Placement); lease.Placement);
_retirementNotification?.Invoke(key);
return true; return true;
} }
if (record.Key is { } completedKey if (record.Key is { } completedKey
@ -853,6 +1016,7 @@ internal sealed class RuntimeInitialCreateResidenceState
lease = completed.Lease; lease = completed.Lease;
cancellation = _setPosition.ForgetExactPlacement( cancellation = _setPosition.ForgetExactPlacement(
lease.Placement); lease.Placement);
_retirementNotification?.Invoke(completedKey);
return true; return true;
} }
lease = default; lease = default;
@ -888,6 +1052,13 @@ internal sealed class RuntimeInitialCreateResidenceState
{ {
_setPosition.PublishCancellation(cancellations[index]); _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() => internal RuntimeInitialCreateResidenceOwnershipSnapshot CaptureOwnership() =>
@ -917,6 +1088,24 @@ internal sealed class RuntimeInitialCreateResidenceState
return _generation?.Invoke() ?? default; return _generation?.Invoke() ?? default;
} }
/// <summary>
/// The staleness check every completed-entry caller shares. Compares the
/// live record against <see cref="CompletedEntry.ExpectedPositionAuthorityVersion"/>
/// et al — an executor-tracked, continuously re-synchronized baseline —
/// rather than against <see cref="RuntimeInitialCreateResidenceReceipt.Token"/>'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
/// <see cref="Complete"/> 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
/// <see cref="AdvanceExecutorBaseline"/>) exactly as it always did. The
/// token itself remains the untouched identity/match key -
/// <see cref="Complete"/>'s <c>completed.Receipt.Token == token</c> check
/// depends on that.
/// </summary>
private bool IsCompletedCurrent(CompletedEntry entry) private bool IsCompletedCurrent(CompletedEntry entry)
{ {
RuntimeInitialCreateResidenceReceipt receipt = entry.Receipt; RuntimeInitialCreateResidenceReceipt receipt = entry.Receipt;
@ -925,35 +1114,147 @@ internal sealed class RuntimeInitialCreateResidenceState
&& _entities.SessionLifetimeVersion && _entities.SessionLifetimeVersion
== receipt.Token.SessionLifetimeVersion == receipt.Token.SessionLifetimeVersion
&& entry.Record.PositionAuthorityVersion && entry.Record.PositionAuthorityVersion
== receipt.Token.PositionAuthorityVersion == entry.ExpectedPositionAuthorityVersion
&& entry.Record.CreateIntegrationVersion && entry.Record.CreateIntegrationVersion
== receipt.Token.CreateIntegrationVersion == entry.ExpectedCreateIntegrationVersion
&& entry.Record.FullCellId == receipt.FullCellId && entry.Record.FullCellId == entry.ExpectedFullCellId
&& entry.Record.PlacementCommitVersion && entry.Record.PlacementCommitVersion
== receipt.PlacementCommitVersion == entry.ExpectedPlacementCommitVersion
&& entry.Lease.Route.Authority.Generation && entry.Lease.Route.Authority.Generation
== CurrentGeneration() == CurrentGeneration()
&& receipt.Token.SessionLifetimeVersion && receipt.Token.SessionLifetimeVersion
== receipt.Adoption.SessionLifetimeVersion == receipt.Adoption.SessionLifetimeVersion
&& receipt.Token.LeaseId == receipt.Adoption.LeaseId && 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.Lease.Route.PerformsSetPosition
|| entry.PlacementAdopted
|| _setPosition.IsPlacementCompletionTracked( || _setPosition.IsPlacementCompletionTracked(
entry.Lease.Placement)); entry.Lease.Placement));
} }
/// <summary>
/// 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 cref="RuntimeEntityKey"/> (see the remarks on
/// <see cref="CompletedEntry.PlacementAdopted"/> for why this is
/// necessary). Idempotent: a retry after <see cref="PlacementAdopted"/> is
/// already true is a no-op success, never a double-consume.
/// </summary>
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;
}
/// <summary>
/// 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 <see cref="AdoptCompletedPlacement"/>,
/// so this does not call <see cref="RuntimeSetPositionState.ConsumeAcknowledgedPlacement"/>
/// a second time for an adopted entry — unlike the host-facing
/// <see cref="AcknowledgeAdoption"/>, which only ever runs for entries the
/// executor has not touched.
/// </summary>
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) private void Retire(Entry entry)
{ {
_entries.Remove(entry.Lease.Token.Entity); RuntimeEntityKey key = entry.Lease.Token.Entity;
_entries.Remove(key);
RuntimePlacementCancellationReceipt cancellation = RuntimePlacementCancellationReceipt cancellation =
_setPosition.ForgetExactPlacement(entry.Lease.Placement); _setPosition.ForgetExactPlacement(entry.Lease.Placement);
_setPosition.PublishCancellation(cancellation); _setPosition.PublishCancellation(cancellation);
_retirementNotification?.Invoke(key);
} }
private void Retire(CompletedEntry entry) private void Retire(CompletedEntry entry)
{ {
_completed.Remove(entry.Receipt.Token.Entity); RuntimeEntityKey key = entry.Receipt.Token.Entity;
_completed.Remove(key);
RuntimePlacementCancellationReceipt cancellation = RuntimePlacementCancellationReceipt cancellation =
_setPosition.ForgetExactPlacement(entry.Lease.Placement); _setPosition.ForgetExactPlacement(entry.Lease.Placement);
_setPosition.PublishCancellation(cancellation); _setPosition.PublishCancellation(cancellation);
_retirementNotification?.Invoke(key);
} }
} }

View file

@ -339,6 +339,76 @@ public sealed class InboundPhysicsStateControllerTests
Assert.Equal((ushort)2, retained.Physics!.Value.Timestamps.Movement); 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] [Fact]
public void FreshForceWithOlderTeleportMirrorsForceButRejectsPose() public void FreshForceWithOlderTeleportMirrorsForceButRejectsPose()
{ {