diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 6a356684..2eae6a17 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -311,9 +311,7 @@ src/ RemotePhysicsUpdater.cs -> ordinary/Hidden remote narrow-tick integration LiveEntityOrdinaryPhysicsUpdater.cs -> manager-less body Transition commits RemoteInboundMotionDispatcher.cs -> animation-optional retail UM funnel - RemoteTeleportController.cs -> incarnation-scoped loaded/pending placement owner - RemoteTeleportHook.cs -> ordered retail teleport teardown seam - RemoteTeleportPlacement.cs -> collision-seated SetPosition transition commit + RemoteTeleportHook.cs -> ordered retail teleport teardown seam (teleport_hook port; C4 route 4b-3 runs it from LiveEntityNetworkUpdateController's teleport arm dispatch, through RuntimeRemotePlacementDriveController) World/ LiveEntityRuntime.cs -> exact-key App projection/lifecycle host LiveEntityProjectionStore.cs -> materialized sidecars by RuntimeEntityKey @@ -466,30 +464,26 @@ What exists and is active: and every routed hook. A delete/local-ID reuse during capture or during an earlier hook can never advance the displaced sequencer or send the old owner's remaining sound, particle, or light hooks to its replacement. -- `RemoteTeleportController` owns the placement half of a fresh remote - teleport after `RemoteTeleportHook` has torn down movement/target state. It - collision-seats loaded destinations through `RemoteTeleportPlacement`; an - unloaded destination retains one generation- and PositionSequence-scoped - pending placement and resolves the latest accepted frame when that same - projection becomes visible. It neither owns GUID identity nor reconstructs - an entity. A placement failure after hydration restores the captured source - frame/cell/contact rather than leaving a visible collisionless projection; - source-resident shadows restore immediately, while an unloaded source - delegates one incarnation-scoped restore to - `LiveEntityPresentationController`, shared with Hidden/UnHide. A newer - placement transfers that marker into an explicit active-placement generation - before rebucketing even while Hidden. That generation suppresses every - intervening Hidden/UnHide and projection restore until the controller reaches - a stable result, then it either restores after collision seating, re-defers - its rollback source, or hands a Hidden result back for UnHide. The typed - `ILiveEntityRemotePlacementRuntime` seam permits a same-incarnation wrapper - rebind only around the canonical body; pending placement adopts that wrapper - before hydration and cannot silently lose ownership. Clearing a motion or - projectile component retains the incarnation's body/contract identity until - logical teardown. The production wrapper exposes an immutable body, while - hydration defensively validates arbitrary implementations against the record - and rolls the retained body back on mismatch. Runtime binding snapshots the - interface Body getter once for validation, assignment, and state mutation. +- **C4 route 4b-3 (2026-08-04) deleted `RemoteTeleportController` / + `RemoteTeleportPlacement` / `RemoteShadowPlacementSynchronizer` outright** — + 605 + 85 + 49 lines of App-layer incarnation-scoped placement machinery, + replaced by routing the remote teleport/cell-less classification through + the SAME canonical `RuntimeRemotePlacementDriveController` the far snap + (C4 route 4b-2) already uses (`ApplyAcceptedRemoteTeleport`, sharing + `StoresAcceptedDestination`/`StoreAcceptedDestinationPose`). `teleport_hook` + (@0x00514ED0) still runs first, via `RemoteTeleportHook` invoked from + `LiveEntityNetworkUpdateController`'s teleport-arm dispatch (both the + player-guid and NPC-guid branches share one `RunRemoteArmTail` helper for + the routing-decision/currency/constraint-arm sequence — see the C4 4b-3 + fix round, 2026-08-04, for why the two branches were unified there after + independently drifting). There is no separate "loaded vs pending + destination" placement path anymore: an unresolved destination collision + generation retains a preparation-stage retry inside the SAME drive + controller that far snap already retries through, not a second incarnation- + scoped machine, and `_activePlacementOwners`'s Hidden/UnHide visibility-edge + protection is gone with its only writer chain — the synchronous, single- + frame teleport commit removes the multi-frame window that protection + existed for. `GpuWorldState` rebuckets atomically and commits spatial visibility before draining its transition FIFO; `LiveEntityRuntime` rejects delayed duplicate edges. A diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index f0f3dad0..ba15e6a9 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -262,9 +262,7 @@ src/AcDream.App/ │ ├── DeferredLiveEntityMotionRuntimeBindings.cs # fail-fast construction-order bridge │ ├── LiveEntityShadowPublisher.cs # authoritative exact-owner/residency collision gate │ ├── RemoteInboundMotionDispatcher.cs # shared animated/headless UpdateMotion funnel -│ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership -│ ├── RemoteTeleportHook.cs # ordered retail teleport teardown actions -│ └── RemoteTeleportPlacement.cs # collision-seated SetPosition transition commit +│ └── RemoteTeleportHook.cs # ordered retail teleport teardown actions (teleport_hook port; C4 4b-3 deleted RemoteTeleportController/RemoteTeleportPlacement/RemoteShadowPlacementSynchronizer — the teleport arm now routes through RuntimeRemotePlacementDriveController, the same canonical placement owner the far snap uses) ├── World/ │ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots │ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation @@ -417,28 +415,19 @@ radar/status targeting, effects, and audio remain closed until reveal. This distinction is part of the Slice E connected gate, not an alternate live-entity lifetime. -Remote teleport placement is bounded in `Physics/RemoteTeleportController`, -not `GameWindow`: it retains at most one pending request per materialized -incarnation, scopes it by the live generation and accepted PositionSequence, -and asks `RemoteTeleportPlacement` to collision-seat the current body when the -destination projection is available. `GameWindow` supplies lifecycle and -shadow-sync callbacks only; canonical identity remains in -`RuntimeEntityDirectory`, and App placement retains the exact projection key. -Failed hydration restores the captured source and delegates an -incarnation-scoped shadow restore to `LiveEntityPresentationController` while -that source is unloaded, so Hidden/UnHide and teleport never become competing -restore owners. A newer placement transfers that restore into an explicit -generation-scoped active-placement state before its rebucket visibility edge -even while Hidden. All intervening Hidden/UnHide and projection edges defer to -that owner until stable success or rollback completes; only then can it restore, -re-defer the source, or hand a Hidden result back for UnHide. The -`ILiveEntityRemotePlacementRuntime` seam keeps the complete cell/contact -handoff available across same-body runtime-wrapper replacement; replacing the -canonical body or dropping the placement contract within one incarnation is -rejected even after an operational component clear. `RemoteMotion.Body` is -constructor-owned; hydration compares pending/current wrappers directly to the -record body rather than trusting wrapper-to-wrapper equality. Binding reads an -interface Body getter once and reuses that snapshot. `GpuWorldState` +**C4 route 4b-3 (2026-08-04) deleted `Physics/RemoteTeleportController` and +`RemoteTeleportPlacement` outright** (605 + 85 lines, plus +`RemoteShadowPlacementSynchronizer`, 49 lines). Remote teleport placement is +no longer a separate App-layer incarnation-scoped machine — it is the SAME +canonical `RuntimeRemotePlacementDriveController` route 4b-2's far snap +already uses, dispatched via `ApplyAcceptedRemoteTeleport`, which shares +`StoresAcceptedDestination`/`StoreAcceptedDestinationPose` unchanged. +`teleport_hook` (@0x00514ED0) still runs first — `RemoteTeleportHook`, +invoked from `LiveEntityNetworkUpdateController`'s teleport-arm dispatch — +and its `report_collision_end(this, 1)` action now routes through +`RuntimeCollisionReportingState.LeaveWorld` (the existing, exact port of +that retail call) rather than `ShadowObjectRegistry.Suspend`, which ports a +DIFFERENT retail function `teleport_hook` never calls. `GpuWorldState` performs remove+place as one spatial rebucket, then commits and serially drains visibility edges; `LiveEntityRuntime` filters delayed duplicates. A rollback inside an observer cannot race the outer diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index e03a8756..bcf6cf6a 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -146,7 +146,7 @@ readiness/requeue adaptation. See | AD-39 | The `frames_stationary_fall` ladder + fsf≥3 UP-contact-plane manufacture runs AFTER acdream's fused LKCP-restore/contact-marking block, deriving retail's `_redo` as `cleanAdvance \|\| OnWalkable`; retail (ACE Transition.cs:1029-1061) interleaves the fsf block BETWEEN the LKCP-restore (sets `_redo`) and the contact-marking (reads the manufactured plane) (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateTransition` fsf tail) | acdream deliberately fused ACE's separate LKCP-restore + contact-mark blocks (the L.2.3c/L.2.4/A6.P3 contact-retention divergences); running the ladder after them and re-marking grounding inside the manufacture branch is semantically equal (a grounded wall-slide is not a stuck-fall in either arrangement) without disturbing those hard-won fixes | If a future contact-retention change alters when OnWalkable is set relative to the ladder, `_redo` could misclassify a frame (grounded-jam mistaken for stuck-fall → spurious velocity zero, or vice-versa) — the fsf conformance tests pin the current arrangement | `CTransition::validate_transition` 0x0050aa70 pc:272625-656; ACE Transition.cs:1029-1061 | | AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) | | AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses the WHOLE SetPositionInternal-shaped commit (contact/walkable flags, HitGround/LeaveGround, `handle_all_collisions`, `cached_velocity`) on a no-move frame — narrowed 2026-07-30 (#265 bounce rework) from "only handle_all_collisions"; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) | -| AD-42 | **Refreshed 2026-08-02 (C3c review round 1).** The two-call enter-world placement split (legacy `Resolve` = retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` = the verbatim object-aware `find_placement_pos` ring search) survives ONLY on the unflipped portal-arrival paths: the remote-teleport controller and the headless portal-arrival resync. The LOCAL login first-entry no longer uses it — the C3c flip routes it through the single canonical Runtime SetPosition transaction (the faithful placement family), retiring the row's original `GameWindow.EnterPlayerModeNow` citation. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Physics/RemoteTeleportController.cs` (`ResolvePlacement`); `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs` (`ResynchronizeLocalPlayerForPortalArrival`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. The surviving split paths are C4's portal-route flip scope | A teleport arrival that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 | +| AD-42 | **Refreshed 2026-08-02 (C3c review round 1). Citation corrected 2026-08-04 (C4 route 4b-3): the remote-teleport controller half is deleted with that slice — the standalone `RemoteTeleportController` no longer exists; the teleport arm now runs through the same canonical Runtime SetPosition transaction the far arm uses, and this row's split survives only on the remaining unflipped path.** The two-call enter-world placement split (legacy `Resolve` = retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` = the verbatim object-aware `find_placement_pos` ring search) survives ONLY on the headless portal-arrival resync. The LOCAL login first-entry no longer uses it — the C3c flip routes it through the single canonical Runtime SetPosition transaction (the faithful placement family), retiring the row's original `GameWindow.EnterPlayerModeNow` citation. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs` (`ResynchronizeLocalPlayerForPortalArrival`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. The surviving split path is the headless portal-route scope | A teleport arrival that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 | | AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 | | AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | | 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` | @@ -283,9 +283,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-133 | **Filed 2026-08-03 (#282).** A retail `CPhysicsObj` has exactly ONE `cell`; `ShouldDrawParticles` @0x0050fe60 reads that same field and calls `IsInView` on it, and `set_cell_id` @0x0050f4f0 / `change_cell` @0x00513390 are the only things that move it. acdream splits the concept into `WorldEntity.ParentCellId` (render parent, null for outdoor dat stabs and building shells) and `WorldEntity.EffectCellId` (authored landcell for those parentless stabs). Every consumer now resolves through the single `WorldEntity.VisibilityCellId` accessor (`ParentCellId ?? EffectCellId`); live entities carry `ParentCellId` only. | `src/AcDream.Core/World/WorldEntity.cs` (`VisibilityCellId`); writers `LandblockLoader.cs:80,97`, `LandblockBuildFactory.cs:408` | Outdoor dat stabs deliberately keep a null render parent so portal visibility does not filter them as interior geometry, yet retail still gives their physics object a landcell for particle gating. One accessor keeps the two fields from being read in conflicting orders, which is exactly how #282 arose - `EntityEffectPoseRegistry` preferred `EffectCellId` while `WbDrawDispatcher` and the remote spawn seed preferred `ParentCellId`. | A future writer that sets `EffectCellId` on a live entity re-creates #282: it wins `VisibilityCellId` while the 11 per-tick `ParentCellId` writers leave it frozen, stranding that entity's particles and lights on a stale cell so they fail `IsInView` after it crosses a boundary. | `CPhysicsObj::ShouldDrawParticles` 0x0050fe60; `CPhysicsObj::set_cell_id` 0x0050f4f0; `CPhysicsObj::change_cell` 0x00513390 | | AP-134 | **Filed 2026-08-03 (#297).** Retail keeps ONE `PublicWeenieDesc::_bitfield` per object and mutates it in place — `SetPlayerKillerStatus` @0x005AC7C0 rewrites bits 5/21/25 (PK `0x20` / Free `0x200000` / PKLite `0x2000000`, mutually exclusive), driven from `ACCWeenieObject::OnStatUpdated` @0x0058DF20 `case 0x86`, and `IsPK`/`IsImpenetrable`/`IsPKLite` @0x0058C8xx read that same field. acdream replicates the value into FIVE stores: `ClientObject.PublicWeenieBitfield` (the source, written only by `ClientObjectTable.UpdateIntProperty` on PropertyInt 134), `InboundPhysicsStateController._snapshots[guid].ObjectDescriptionFlags`, `RuntimeEntityRecord.Snapshot.ObjectDescriptionFlags`, the decoded `ShadowObjectRegistry` registration + per-cell `ShadowEntry.Flags`, and the local player's `RuntimeMovementSkillState` own-PWD bitfield. Coherence is maintained by two `ObjectUpdated` subscribers (`RuntimeEntityPvpBitfieldSnapshotSync` for the two snapshot stores, `LiveEntityPvpBitfieldSync` for the decoded shadow flags) plus the appearance-rebuild path re-deriving from the snapshot. The two shadow-flag writers are the SAME invalidation applied at the two edges that can invalidate it, not competing authorities. | `src/AcDream.Runtime/Entities/RuntimeEntityPvpBitfieldSnapshotSync.cs`; `src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs`; source writer `src/AcDream.Core/Items/ClientObjectTable.cs` (`UpdateIntProperty`, PropertyInt 134); decode `EntityCollisionFlagsExt.FromPwdBitfield` | ACE never re-sends a `PublicWeenieDesc` after login (`EnqueueBroadcastUpdateObject` has zero live callers), so PropertyInt 134 over 0x02CE/0x02CD is the ONLY signal a PK status changed — a client cannot learn it from the bitfield itself. The replication exists because acdream separates wire snapshots, canonical records, and the collision shadow registry, which retail does not; each layer needs the decoded value at a different lifetime. Before #297 the snapshot stores were immutable wire captures; this commit is what converts them into write-through caches, and therefore what creates the invariant. | Any future write path that sets `ClientObject.PublicWeenieBitfield` outside `UpdateIntProperty`, or any NEW decoded cache of the PK bits, silently re-creates #297: the player walks through PKLite opponents and melee/missile admission refuses them, with no test failing. Note the same class already exists one field over — `Properties.Ints[134]` is written by `UpsertProperties` (PlayerDescription 0x0013) and `UpdateProperties` (IdentifyObjectResponse) WITHOUT mirroring into the bitfield (#300), and retail's `OnStatUpdated` also rewrites `_blipColor` (`case 0x5f`) and `_radar_enum` (`case 0x85`) which acdream ignores entirely (#301). | `PublicWeenieDesc::SetPlayerKillerStatus` 0x005AC7C0; `ACCWeenieObject::OnStatUpdated` 0x0058DF20 (`case 0x86`); `ACCWeenieObject::IsPK`/`IsImpenetrable`/`IsPKLite` 0x0058C8xx; retail `PKStatusEnum` `acclient.h:6412-6427` | | AP-135 | **Filed 2026-08-03 (C4 route 4a).** Retail `CPhysicsObj::MoveOrTeleport` 0x00516330 writes NOTHING on the airborne no-op (`arg4 == 0` -> `return 0` @0x0051636D), and `SmartBox::HandleReceivedPosition` 0x00453FD0 skips `ConstrainTo` with it (@0x00454272 sits inside `if (MoveOrTeleport(...) != 0)` @0x00454254). acdream honours that for every retail-modeled write — body pose, interpolation queue, leash, render entity, collision shadow, and the AP-80 velocity-derived animation cycle — but deliberately KEEPS two acdream-only per-packet bookkeeping writes on that branch: `RemoteMotion.CellId = wire landblock` and the `LastServerPos`/`LastServerPosTime` sample. This was pre-existing player-remote behaviour; route 4a extends it to NPC remotes so both arms are identical | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, both remote airborne-no-op returns) | The cell id is what acdream's OWN per-tick free-fall `ResolveWithTransition` sweep gates on (`rm.CellId != 0`); without it an airborne remote's sphere sweep is skipped and it falls through the floor (#42's neighbourhood). The server sample is what the first grounded packet after the arc synthesizes its velocity from; dropping it would make that velocity span the whole jump. Neither is a retail `CPhysicsObj` field being written | A remote's cell membership tracks the server's landblock during an arc where retail would keep the cell its own physics last resolved. Visible only if the server's mid-arc landblock disagrees with the client's swept cell — the wire cell is authoritative in every case acdream has observed. Retire together with the free-fall sweep gate, when the remote arc is resolved by the same transition machinery the local player uses | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x0051636D `return 0`); `SmartBox::HandleReceivedPosition` 0x00453FD0 (@0x00454254/@0x00454272) | -| AP-136 | **Filed 2026-08-04 (C4 route 4b-1 review). AMENDED 2026-08-04 (cancelled-park presentation rollback): this row's central claim — "the entity becomes VISIBLE IMMEDIATELY at the committed destination pose" — was true only of the CANONICAL half until that fix, and the gap was a defect, not a divergence.** `ParkDeferred` publishes a `Withdraw` receipt whose presentation half the host sink performs (graphical bucket, projection visibility, plugin world state/events, effect-pose registry, local-player shadow, selection), and `RestoreParkWithdrawal` cannot reach any of it. Its only mirror image was a LATER `Place`, which a remote that parks on its final Position and then stops moving never receives, because ACE stops broadcasting for a stationary entity — so the entity was left simulated, collidable, and audible while ABSENT from both the world render and the radar for the rest of the session. The rollback now publishes `RuntimePlacementProjectionKind.WithdrawalRestored` on the same ordered receipt stream, gated on the entity ending the rollback canonically whole (`FullCellId != 0` and `InWorld`) — deliberately NOT on this row's residency arm alone, which the shipped graphical remote path correctly skips because its per-packet prologue rebucket already recommitted a non-zero `FullCellId`. The AP-136 residual below is unchanged and is now actually observable. Selection alone is not re-established: see AD-63. Retail has NO cancel for a lost-cell park. `CPhysicsObj::SetPositionInternal` @0x00515BD0 commits the destination pose with `store_position` @0x00515CE2 and registers the object via `CObjectMaint::GotoLostCell` @0x00515CF2 (@0x00508210); the registration is removed by exactly one thing, `CObjectMaint::InitObjCell` @0x00508260, which drains the lost list on cell load and calls `CPhysicsObj::reenter_visibility` @0x00508296 (@0x00516250) to re-place at the committed pose. An update that performs no SetPosition leaves the registration untouched, so retail keeps the object HIDDEN until its cell loads. acdream's accepted-Position merge cancels the park instead (a shipped, tested invariant), so on cancel we roll the withdrawal back — `InWorld`, object clock, canonical residency — and the entity becomes VISIBLE IMMEDIATELY at the committed destination pose, uncollidable until its landblock publishes. The pose itself is retail-exact and is deliberately not rolled back. The `ShadowObjectRegistry.Suspend` applied by `WithdrawCanonical` is also not lifted, because un-suspending needs a real placement dispatch (`ReplacePositionRows`); the entity rejoins the broadphase on its next placement | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s `restorableOnCancel`, `Forget`, `RestoreParkWithdrawal`) | The alternative — leaving the cancelled park's withdrawal in place — strands the entity invisible AND intangible for the rest of the session, because `CancelCoreDeferred` restores none of it and the operation that was the only thing able to wake it is gone. Restoring at the pre-park pose was tried and is wrong: retail commits the destination pose, and route 2's tests pin that the pose survives the cancel. Restore covers the plain unplaceable-destination park and — **narrowed 2026-08-04 at the C4 route 4b-2 delta review, then relocated at that slice's round 3** — `SubmitPreparedPlacementCore`'s two collision-prefix-QUIESCENCE parks. **Corrected round 4 (D1): NOT "unconditionally" for any of the three.** Since the relocation, the same post-snap quiescence test gates EVERY park including the plain one, which the row's own next sentences already described; the word contradicted them. The original blanket "no quiescence park is restorable" was over-broad: its stated reason — re-admitting a spatial root into a retiring prefix blocks the retirement — is exact for `ParkCollisionResidents`, where the entity's OWN cell is retiring, but `TryGetBlockingQuiescence` also fires on prefixes the placement merely TOUCHES (any `QueriedCellIds` entry, i.e. a NEIGHBOUR landblock the sweep crossed a seam into; and the request's `CurrentCellId`, which on a FIRST submit names the destination rather than the departed source because both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting — scoped at round 4 (D5): a retained retry re-submits with no fresh merge, and `RemoteTeleportController`'s rollback can rebucket that field to the pre-teleport landblock, so the arm is live). The decision is taken inside `ParkDeferred`, AFTER `SnapToCell`, as `!IsCollisionPrefixQuiescing(body.CellPosition.ObjCellId)` — the cell `RestoreParkWithdrawal` will actually restore residency into, tested against EVERY live quiescence rather than against the single minimum-`OperationId` token `TryGetBlockingQuiescence` happened to return, and read after `LandDefs.AdjustToOutside` may have moved it (the reachable half of that, and the one a test now pins, is the re-derived cell: a wire (cell, position) pair whose position lies past its own named block's seam is exactly the pair #107's re-derivation distrusts, and it lands residency in a NEIGHBOUR landblock — see `QuiescingOwnPrefix_SeamCrossingParkIsRestoredAtTheReDerivedNeighbourCell`). A cell id of 0 is `AdjustToOutside`'s map-edge failure sentinel, never landblock (0,0), so the map is not consulted with it (C3c-F3). **Round 4 (D6) added the same test at RESTORE time**, in `RestoreParkWithdrawal`'s residency arm: the park-time answer is a snapshot, and route 2's park is RETAINED until the next packet's merge-time `Forget` ~150 ms later, so a prefix clean when the park was taken can be quiescing when the rollback runs. The `InWorld`/transient/clock half is still restored unconditionally — it is per-entity simulation state, not a claim on any landblock's collision generation. So the rollback re-admits nothing into ANY quiescing prefix, at the moment it actually writes residency rather than only as of when the park was taken, while leaving these parks non-restorable stranded the entity `InWorld = false` / clock suspended / not a spatial root with the only operation able to wake it destroyed by its own next accepted Position. A retirement park (`ParkCollisionResidents`) is still never restored, and now says so explicitly rather than relying on a parameter default | A remote — or the LOCAL PLAYER, which traverses the same shared core through route 2 — that teleports into a non-resident landblock and then STOPS MOVING stays visible at the destination without collision, where retail would hide it and re-show it on cell load — ACE stops broadcasting for a stationary entity, so no later packet corrects it. At 5-10 Hz the ordinary case is superseded within ~150 ms. Retire by making the park SURVIVE cancellation (issue #309), which is blocked on re-deciding the newer-Position-cancels-the-park invariant pinned by `NewerPositionPickupAndParentEachCancelExactLostOperation` and on teardown convergence. **This row carries a user-observable change to shipped paths** — `restorableOnCancel: true` sits in `SubmitPreparedPlacementCore`, the shared core behind every production placement — so it needs the two-client connected check written up in #309 — **rewritten by this slice, not merely extended (round-4 D2 correction: this summary used to describe only the original three remote steps plus "route 2's corrections are unchanged", which no longer matches the issue's own body)**. #309 is now six steps run with `ACDREAM_PROBE_PARK=1`: the three original remote-park steps, plus a quiescing swept-NEIGHBOUR step and a quiescing-DESTINATION step that both exercise the LOCAL PLAYER through route 2 and both carry a stated `[park]`/`[park-restore]` confirmation signal (a quiescence window cannot be synchronised by hand, so without one the step passes while broken), plus the unchanged-ordinary-correction step. Step 5 also asks the tester to confirm the destination landblock's retirement still COMPLETES, and states correctly that the deliberately non-restored park does NOT recover on the next ordinary Position | `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D/@0x00515CDA/@0x00515CE2/@0x00515CF2/@0x00515CF7/@0x00515D07); `CObjectMaint::GotoLostCell` @0x00508210; `CObjectMaint::InitObjCell` @0x00508260 (@0x00508296); `CPhysicsObj::reenter_visibility` @0x00516250 | -| AP-137 | **Filed 2026-08-04 (C4 route 4b-2); rewritten same day at the dual Opus review.** acdream can classify a remote's accepted Position into three states retail cannot reach, and they now share ONE stated handler instead of a duplicated near/far block. The states: (a) **no classification at all** — `RuntimeAcceptedPositionRouteRequests.TryBuild` refuses to fabricate a local-player position, so `ClassifyRemoteAcceptedPosition` returns null for EVERY remote packet until the local movement controller exists (the login window) and whenever the canonical record has not claimed a local id; (b) **`RejectedAuthority`/`RejectedData`** — acdream validates wire authority and payload finiteness, retail validates neither; (c) the **cell-less `SetPosition`** half, which retail routes through `this_1->cell == 0` @0x00516386 and route 4b-3 will own. All three take AP-87's shared `ApplyInterpolate` catch-up (`RuntimeRemoteFarSnapPosition.ResolveArm`'s `UnroutedCatchUp`). **R1 — what the deleted far test actually computed (the first version of this row was wrong).** It claimed the deleted `_playerController?.Position ?? Vector3.Zero` distance had "no relationship to `player_distance`". Not true: `worldPos` is streaming-origin-relative (local position + `(landblock − _origin.Center) × 192 m`) and the streaming origin recentres on the local player's landblock, so the fabricated distance measured the remote's range from the ORIGIN LANDBLOCK'S CORNER — a biased but genuinely correlated proxy, error bounded by the player's own offset inside that landblock (0–192 m per axis). It is deleted anyway because a silently-biased proxy for an exact 96 m threshold is not a threshold: the bias reaches ~2.8× the threshold, so the arm it selects is frequently not the arm retail selects, and correcting it needs exactly the player position the classifier declined to fabricate. **R2 — the cell-less delta, stated.** Retail's cell-less arm is an UNCONDITIONAL placement sitting AHEAD of the contact test (`teleport_hook` @0x005163EF, `SetPosition` flags `0x1012` @0x00516420, `return 1` @0x00516438); acdream now ENQUEUES that classification whenever `!firstUp && willBeDrTicked && bodyToTarget <= 4 m`, at ANY distance, not only ≥96 m. **Deliberately not changed to place in 4b-2**: retail's arm is not a pose write, it is `teleport_hook` @0x00514ED0 — the COMPLETE call list, corrected at the 2026-08-04 delta review, which found the earlier enumeration had dropped the last entry: `MovementManager::CancelMoveTo` @0x00514EDF, `PositionManager::UnStick` @0x00514EEE, `PositionManager::StopInterpolating` @0x00514EFD, `PositionManager::UnConstrain` @0x00514F0C, `TargetManager::ClearTarget` @0x00514F1B + `TargetManager::NotifyVoyeurOfEvent(Teleported_TargetStatus)` @0x00514F28, and `CPhysicsObj::report_collision_end(this, 1)` @0x00514F31 — followed by the canonical flags-`0x1012` `SetPosition` — the classifier itself records this as `TeleportHookPhase.BeforePositionOperation`. Writing only the pose would leave a live moveto, a live stick, and a leash anchored to the old cell, strictly worse than the queue. Porting the whole arm is route 4b-3's entire scope. **R3 — `RejectedData` is APPLIED anyway.** It is the one classification meaning "this payload failed validation" (`ClassifyAcceptedPosition` emits it for a `ValidPosition` failure and for a non-finite/negative derived `player_distance`), and `UnroutedCatchUp` hands the same payload to `ApplyInterpolate`. Not a regression — the legacy block did the same — but the slice's stated purpose was an explicit handler, so it is named. **Headless (contract item 6) is satisfied vacuously and that is stated, not implied:** nothing in `AcDream.Headless` constructs `RuntimeRemotePlacementDriveController` (`SessionPlayerComposition` is the only construction site) and `RuntimeLiveEntitySessionController.OnPositionUpdated` returns early for every non-local GUID, so the far snap is a graphical-host-only path | `src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs` (`ResolveArm`, `RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp`); applied at `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ApplyRemoteContactRouting`'s default arm); headless statement on `IRuntimeRemotePlacementServiceWindow` | AP-87's own snap conditions (`firstUp \|\| !willBeDrTicked \|\| bodyToTarget > 4 m`) still PLACE an unplaced or badly-lagging body, so a remote keeps tracking the server through the login window and through a rejected packet — this arm is never frozen. The deleted far test could not have been preserved honestly: one of its three residual inputs is an uncorrectably biased fabrication, one is unreachable (the classifier returns before evaluating distance), and the third contradicted retail's own branch order | A leftover-classified remote beyond 96 m that is already tracking catches up over a packet interval instead of snapping — invisible in practice at that range, but a real change to the cell-less path that route 4b-3 must re-check when it takes ownership, together with the two rejections' own arm. If AP-87's 4 m backstop were ever weakened, this arm would become the silent-freeze path the route 4b scoping named as its trap | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x00516386 cell-0/teleport, @0x005163AF near, @0x005163C1-E8 far); `CPhysicsObj::teleport_hook` @0x00514ED0; `RuntimeAcceptedPositionRouteRequests.TryBuild`; `GameRuntime.cs:288-290` (the no-fabricated-Vector3.Zero rule) | -| AP-138 | **Filed 2026-08-04 (C4 route 4b-2, dual Opus review).** Retail's remote far snap is unconditional and unrefusable: `CPhysicsObj::MoveOrTeleport` @0x005163D9 calls `SetPositionSimple`, discards its `SetPositionError`, and returns 1 @0x005163E8, so `SmartBox::HandleReceivedPosition` arms `ConstrainTo` @0x00454272 every time. acdream's far snap is a canonical Runtime placement that can decline for reasons retail has no analogue for, and this row records the complete residual. **(1) An outcome that never reached the engine is a `store_position`; one that did is not.** Retail's `SetPositionInternal` @0x00515BD0 has exactly two shapes and acdream now represents both (**corrected 2026-08-04 at the delta review, which found the first version of this row asserting — wrongly — that no acdream non-commit outcome could represent the second**). STORES, because the resolve never ran: `Refused` (the pre-flight declined the destination), `Contention` (another authority owns the operation, or the Setup/world-frame preparation is retryable), `RejectedPreparation` (`RejectedAuthority`/`InvalidData` — preparation refused before anything was submitted), and `NotApplicable`. For those `ApplyAcceptedRemoteFarSnap` writes the accepted destination pose to the canonical body, exactly as retail commits it on the no-transition branch — `prepare_to_leave_visibility` @0x00515CDA, `store_position` @0x00515CE2, `GotoLostCell` @0x00515CF2, `return 0` @0x00515D07 — so the remote keeps tracking the server at 5-10 Hz, at the destination, with no resolved cell; retail would additionally have hidden it until cell load, which is AP-136's scope, not this one. DOES NOT STORE, because the resolve DID run and refused: `RejectedByPlacement` (`PhysicsEngine.SetPosition` returned a non-Ok error, acdream's port of retail's `CheckPositionInternal == 0` @0x00515C85/@0x00515CD5 and `curr_cell == 0` @0x00515C8F/@0x00515CB2, neither of which stores; or authority displaced after the engine ran, which includes the `CommitCanonical`-already-settled shape) and `Deferred` (Core parked, and `ParkDeferred` has ALREADY snapped the body to the parked result — the accepted destination for the pre-sweep park, the collision-settled `spherePath.CurPos` for the post-sweep one — which `RestoreParkWithdrawal` deliberately leaves alone). **(2) A quiescence park a far snap can provoke is now restorable at the source, not refused by a pre-flight.** **Rewritten 2026-08-04 at the delta review.** `CanAttemptDestination` (service window + Core's own `IsCollisionPrefixQuiescing`) reads ONE prefix, the destination's, and stays as an optimisation. It cannot be the correctness mechanism: Core's `PlacementTouchesPrefix` also matches the request's `CurrentCellId` (see the round-3 measurement below for what that arm actually names), and `ResultTouchesPrefix` scans every `QueriedCellIds` entry, a sweep footprint that spans NEIGHBOUR landblocks (`CellTransit.AddOutsideCell` re-derives the block id from the global lcoord and has no same-block filter) and does not EXIST until the sweep has run. Worse, the post-sweep check is `result.IsSuccessful && TryGetBlockingQuiescence(result, …)` and sits ahead of the restorable `result.IsDeferred` park, so a healthy about-to-COMMIT far snap near a seam was rewritten to `DeferredCell` and parked non-restorably. The fix is in `SubmitPreparedPlacementCore`: both quiescence parks are restorable, and `ParkDeferred` decides safety on the cell it will actually restore into — see AP-136 for the exact predicate and for why it does not re-open the retirement stall AP-136's blanket scoping was protecting against. On a FIRST submit the `CurrentCellId` half of `PlacementTouchesPrefix` is NOT the "source landblock a far snap is leaving": both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting (the graphical remote path through `LiveEntityRuntime.RebucketLiveEntity` in its shared prologue, route 2 through the merge), so that arm names the destination — measured 2026-08-04 at round 3. **Scoped at round 4 (D5): that is a first-submit property only, and the arm is live rather than dead code.** A RETAINED operation re-submits from its own cadence pump with no fresh merge (both drives re-read `record.FullCellId` at submit), and `RemoteTeleportController`'s rollback is a shipped writer that rebuckets it back to the PRE-teleport landblock, so a retry can genuinely name a third landblock — which `CanAttemptDestination`'s own doc already said and the two summaries elsewhere contradicted. **(3) The leash is not armed through a superseded incarnation.** Retail arms unconditionally on the nonzero return; acdream re-validates position ownership after the placement (the receipt is published synchronously and the projection sink can replace or delete the incarnation from inside it) and returns without arming if the owner moved. Both remote arms now run that check BEFORE their arming call — the player arm used to arm first, the NPC arm second, and one of the two mirror images had to be wrong | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`RuntimeRemotePlacementExecutionStatus` + `StoresAcceptedDestination`, `ApplyAcceptedRemoteFarSnap`, `StoreAcceptedDestinationPose`, `Advance`'s window-drop path, `CanAttemptDestination`, `SubmitAndResolve`); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s post-snap restorable decision and the two `SubmitPreparedPlacementCore` quiescence parks); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (both arms' re-validate-then-arm order) | The alternative to (1) is the shipped pre-review state: an emptied interpolation queue plus a stale body pose, i.e. a frozen remote that the next packet reproduces identically, since nothing about a refusal reason changes at packet cadence. That is strictly further from retail than either the deleted legacy block (which always tracked) or retail itself. The alternative tried and rejected in between — storing on EVERY non-commit outcome — is worse still in the other direction: it teleports the canonical body into a destination the engine's own sweep just refused, and overwrites a freshly settled pose (contact plane, step-down) whenever `CommitCanonical` landed and only the projection ownership was displaced. The alternative to (2) — keeping the pre-flight as the correctness mechanism and widening it — is structurally impossible, because the swept footprint half of Core's predicate does not exist until the sweep has run; the alternative of leaving the parks non-restorable strands the remote outright. The alternative to (3) — arming a leash on a host that is no longer the entity's canonical position owner — is a write through superseded state, the exact class the re-validation exists to prevent, and retail has no superseded-incarnation state for its unconditional arm to arbitrate | A remote whose destination this host cannot place into keeps moving and rendering but does not become collidable or cell-resident until a later packet commits — it can be walked through at range. Bounded by the 5-10 Hz packet stream and by how long the destination stays unpublished/quiescing. A remote whose destination the ENGINE refuses, or whose commit was displaced, keeps its last resolved pose for that packet instead of tracking — retail-exact, but it means a remote can look one packet stale near geometry it cannot be placed into. A quiescence park whose blocking prefix is a swept neighbour re-shows the entity immediately at the destination rather than hiding it until cell load (AP-136's own residual, now reachable through this path and through route 2's local-player corrections). A superseded incarnation's leash is left unarmed for one packet; the replacement incarnation arms its own on its next accepted Position. Retire (1) by making the far arm's failure path open retail's lost-cell registration instead of a bare pose write, which is issue #309's territory (the park must survive cancellation first) | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x005163D9, @0x005163E8); `CPhysicsObj::SetPositionSimple` @0x005162B0 (flags `0x1012` @0x005162C4); `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D, @0x00515CDA, @0x00515CE2, @0x00515CF2, @0x00515CB2, @0x00515CD5, @0x00515D07); `SmartBox::HandleReceivedPosition` @0x00453FD0 (@0x00454254, @0x00454272) | +| AP-136 | **Filed 2026-08-04 (C4 route 4b-1 review). AMENDED 2026-08-04 (cancelled-park presentation rollback): this row's central claim — "the entity becomes VISIBLE IMMEDIATELY at the committed destination pose" — was true only of the CANONICAL half until that fix, and the gap was a defect, not a divergence.** `ParkDeferred` publishes a `Withdraw` receipt whose presentation half the host sink performs (graphical bucket, projection visibility, plugin world state/events, effect-pose registry, local-player shadow, selection), and `RestoreParkWithdrawal` cannot reach any of it. Its only mirror image was a LATER `Place`, which a remote that parks on its final Position and then stops moving never receives, because ACE stops broadcasting for a stationary entity — so the entity was left simulated, collidable, and audible while ABSENT from both the world render and the radar for the rest of the session. The rollback now publishes `RuntimePlacementProjectionKind.WithdrawalRestored` on the same ordered receipt stream, gated on the entity ending the rollback canonically whole (`FullCellId != 0` and `InWorld`) — deliberately NOT on this row's residency arm alone, which the shipped graphical remote path correctly skips because its per-packet prologue rebucket already recommitted a non-zero `FullCellId`. The AP-136 residual below is unchanged and is now actually observable. Selection alone is not re-established: see AD-63. Retail has NO cancel for a lost-cell park. `CPhysicsObj::SetPositionInternal` @0x00515BD0 commits the destination pose with `store_position` @0x00515CE2 and registers the object via `CObjectMaint::GotoLostCell` @0x00515CF2 (@0x00508210); the registration is removed by exactly one thing, `CObjectMaint::InitObjCell` @0x00508260, which drains the lost list on cell load and calls `CPhysicsObj::reenter_visibility` @0x00508296 (@0x00516250) to re-place at the committed pose. An update that performs no SetPosition leaves the registration untouched, so retail keeps the object HIDDEN until its cell loads. acdream's accepted-Position merge cancels the park instead (a shipped, tested invariant), so on cancel we roll the withdrawal back — `InWorld`, object clock, canonical residency — and the entity becomes VISIBLE IMMEDIATELY at the committed destination pose, uncollidable until its landblock publishes. The pose itself is retail-exact and is deliberately not rolled back. The `ShadowObjectRegistry.Suspend` applied by `WithdrawCanonical` is also not lifted, because un-suspending needs a real placement dispatch (`ReplacePositionRows`); the entity rejoins the broadphase on its next placement | `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s `restorableOnCancel`, `Forget`, `RestoreParkWithdrawal`) | The alternative — leaving the cancelled park's withdrawal in place — strands the entity invisible AND intangible for the rest of the session, because `CancelCoreDeferred` restores none of it and the operation that was the only thing able to wake it is gone. Restoring at the pre-park pose was tried and is wrong: retail commits the destination pose, and route 2's tests pin that the pose survives the cancel. Restore covers the plain unplaceable-destination park and — **narrowed 2026-08-04 at the C4 route 4b-2 delta review, then relocated at that slice's round 3** — `SubmitPreparedPlacementCore`'s two collision-prefix-QUIESCENCE parks. **Corrected round 4 (D1): NOT "unconditionally" for any of the three.** Since the relocation, the same post-snap quiescence test gates EVERY park including the plain one, which the row's own next sentences already described; the word contradicted them. The original blanket "no quiescence park is restorable" was over-broad: its stated reason — re-admitting a spatial root into a retiring prefix blocks the retirement — is exact for `ParkCollisionResidents`, where the entity's OWN cell is retiring, but `TryGetBlockingQuiescence` also fires on prefixes the placement merely TOUCHES (any `QueriedCellIds` entry, i.e. a NEIGHBOUR landblock the sweep crossed a seam into; and the request's `CurrentCellId`, which on a FIRST submit names the destination rather than the departed source because both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting — scoped at round 4 (D5): a retained retry re-submits with no fresh merge, and a non-Position rebucket writer (the projection materializer `DatLiveEntityProjectionMaterializer`, or the equipped-child renderer `EquippedChildRenderController.TickChild` — C4 route 4b-3 deleted the third shipped writer, `RemoteTeleportController`'s rollback) can rebucket that field to a third landblock, so the arm is live). The decision is taken inside `ParkDeferred`, AFTER `SnapToCell`, as `!IsCollisionPrefixQuiescing(body.CellPosition.ObjCellId)` — the cell `RestoreParkWithdrawal` will actually restore residency into, tested against EVERY live quiescence rather than against the single minimum-`OperationId` token `TryGetBlockingQuiescence` happened to return, and read after `LandDefs.AdjustToOutside` may have moved it (the reachable half of that, and the one a test now pins, is the re-derived cell: a wire (cell, position) pair whose position lies past its own named block's seam is exactly the pair #107's re-derivation distrusts, and it lands residency in a NEIGHBOUR landblock — see `QuiescingOwnPrefix_SeamCrossingParkIsRestoredAtTheReDerivedNeighbourCell`). A cell id of 0 is `AdjustToOutside`'s map-edge failure sentinel, never landblock (0,0), so the map is not consulted with it (C3c-F3). **Round 4 (D6) added the same test at RESTORE time**, in `RestoreParkWithdrawal`'s residency arm: the park-time answer is a snapshot, and route 2's park is RETAINED until the next packet's merge-time `Forget` ~150 ms later, so a prefix clean when the park was taken can be quiescing when the rollback runs. The `InWorld`/transient/clock half is still restored unconditionally — it is per-entity simulation state, not a claim on any landblock's collision generation. So the rollback re-admits nothing into ANY quiescing prefix, at the moment it actually writes residency rather than only as of when the park was taken, while leaving these parks non-restorable stranded the entity `InWorld = false` / clock suspended / not a spatial root with the only operation able to wake it destroyed by its own next accepted Position. A retirement park (`ParkCollisionResidents`) is still never restored, and now says so explicitly rather than relying on a parameter default | A remote — or the LOCAL PLAYER, which traverses the same shared core through route 2 — that teleports into a non-resident landblock and then STOPS MOVING stays visible at the destination without collision, where retail would hide it and re-show it on cell load — ACE stops broadcasting for a stationary entity, so no later packet corrects it. At 5-10 Hz the ordinary case is superseded within ~150 ms. Retire by making the park SURVIVE cancellation (issue #309), which is blocked on re-deciding the newer-Position-cancels-the-park invariant pinned by `NewerPositionPickupAndParentEachCancelExactLostOperation` and on teardown convergence. **This row carries a user-observable change to shipped paths** — `restorableOnCancel: true` sits in `SubmitPreparedPlacementCore`, the shared core behind every production placement — so it needs the two-client connected check written up in #309 — **rewritten by this slice, not merely extended (round-4 D2 correction: this summary used to describe only the original three remote steps plus "route 2's corrections are unchanged", which no longer matches the issue's own body)**. #309 is now six steps run with `ACDREAM_PROBE_PARK=1`: the three original remote-park steps, plus a quiescing swept-NEIGHBOUR step and a quiescing-DESTINATION step that both exercise the LOCAL PLAYER through route 2 and both carry a stated `[park]`/`[park-restore]` confirmation signal (a quiescence window cannot be synchronised by hand, so without one the step passes while broken), plus the unchanged-ordinary-correction step. Step 5 also asks the tester to confirm the destination landblock's retirement still COMPLETES, and states correctly that the deliberately non-restored park does NOT recover on the next ordinary Position | `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D/@0x00515CDA/@0x00515CE2/@0x00515CF2/@0x00515CF7/@0x00515D07); `CObjectMaint::GotoLostCell` @0x00508210; `CObjectMaint::InitObjCell` @0x00508260 (@0x00508296); `CPhysicsObj::reenter_visibility` @0x00516250 | +| AP-137 | **Filed 2026-08-04 (C4 route 4b-2); rewritten 2026-08-04 at the dual Opus review; REWRITTEN AGAIN 2026-08-04 (C4 route 4b-3) — the cell-less enqueue-vs-place delta this row existed to record is RETIRED, not merely re-scoped: the teleport arm now ports retail's `teleport_hook` verbatim and places unconditionally through the canonical Runtime placement owner, exactly like retail's `this_1->cell == 0` @0x00516386 branch. What survives is the two acdream-only divergences retail has no state for at all.** acdream can classify a remote's accepted Position into two states retail cannot reach, sharing ONE stated handler (`RuntimeRemoteFarSnapPosition.ResolveArm`'s `UnroutedCatchUp`, AP-87's shared `ApplyInterpolate` catch-up) instead of a duplicated near/far block. The states: (a) **no classification at all** — `RuntimeAcceptedPositionRouteRequests.TryBuild` refuses to fabricate a local-player position, so `ClassifyRemoteAcceptedPosition` returns null for EVERY remote packet until the local movement controller exists (the login window) and whenever the canonical record has not claimed a local id; **route 4b-3 adds two more null-producing reasons** — the merge observed no PRIOR canonical record for this entity, so the classifier has no honest pre-merge cell to feed the teleport predicate and declines rather than fabricate one (D1); and the dormant initial-residence enqueue path (`RuntimeEntityObjectLifetime.TryApplyPosition`'s `EnqueueDormant` return, reached BEFORE the method's own `PreMergeCommittedCellId` write), whose timestamps therefore always carry `PreMergeCommittedCellId: null` too — fix round 2026-08-04 (R8), unverified from static reading whether `OnPosition` reaches `ClassifyRemoteAcceptedPosition` for an enqueued packet at all, stated honestly rather than guessed; (b) **`RejectedAuthority`/`RejectedData`** — acdream validates wire authority and payload finiteness, retail validates neither. **D1 — the visibility arm is deleted, not merely narrowed.** Before 4b-3, `LiveEntityRuntime.TryApplyPosition` computed `projectionRequiresTeleportHook` as `pre-merge FullCellId == 0 OR !IsSpatiallyProjected OR !IsSpatiallyVisible` — a presentation predicate with NO retail analogue, since retail's `MoveOrTeleport` never reads visibility. That whole computation, the lifetime parameter, and the headless `false` argument are deleted; a not-visible remote's Position now classifies purely by distance/contact like any other, and visibility is presentation-only. **D2 — the wire-airborne leftover shape.** After the teleport/cell-less classification moves onto its own arm, a packet whose classification is null/`RejectedAuthority`/`RejectedData` AND whose wire contact bit is clear takes retail's return-0 shape: AP-135's two bookkeeping writes only (server-cell adopt, `LastServerPos`/`LastServerPosTime`), no body/queue/render write, no leash arm. This deletes the legacy player-arm fallback's entity-revert quirk (`entity.SetPosition(rmState.Body.Position)`) and unifies player and NPC remotes on one behaviour. **R3 (retained from the prior rewrite) — `RejectedData` is APPLIED anyway** when grounded. It is the one classification meaning "this payload failed validation" (`ClassifyAcceptedPosition` emits it for a `ValidPosition` failure and for a non-finite/negative derived `player_distance`), and `UnroutedCatchUp` hands the same payload to `ApplyInterpolate`. Not a regression — the legacy block did the same. **Headless (contract item 6) is satisfied vacuously and that is stated, not implied:** nothing in `AcDream.Headless` constructs `RuntimeRemotePlacementDriveController` (`SessionPlayerComposition` is the only construction site) and `RuntimeLiveEntitySessionController.OnPositionUpdated` returns early for every non-local GUID, so both the far snap and the teleport arm are graphical-host-only paths | `src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs` (`ResolveArm`, `RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp`); `src/AcDream.Runtime/Physics/RuntimeRemoteTeleportPosition.cs` (`OwnsTeleportPlacement`, the retired predicate's replacement); applied at `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ApplyRemoteContactRouting`'s teleport check and default arm, the D2 wire-airborne shape); headless statement on `IRuntimeRemotePlacementServiceWindow` | AP-87's own snap conditions (`firstUp \|\| !willBeDrTicked \|\| bodyToTarget > 4 m`) still PLACE an unplaced or badly-lagging body for the two survivors, so a remote keeps tracking the server through the login window and through a rejected packet | The two survivors are unaffected by 4b-3: a leftover-classified remote beyond 96 m that is already tracking catches up over a packet interval instead of snapping — invisible in practice at that range. If AP-87's 4 m backstop were ever weakened, this arm would become a silent-freeze path | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x00516386 cell-0/teleport — now ported, @0x005163AF near, @0x005163C1-E8 far); `CPhysicsObj::teleport_hook` @0x00514ED0; `RuntimeAcceptedPositionRouteRequests.TryBuild`; `GameRuntime.cs:288-290` (the no-fabricated-Vector3.Zero rule) | +| AP-138 | **Filed 2026-08-04 (C4 route 4b-2, dual Opus review).** Retail's remote far snap is unconditional and unrefusable: `CPhysicsObj::MoveOrTeleport` @0x005163D9 calls `SetPositionSimple`, discards its `SetPositionError`, and returns 1 @0x005163E8, so `SmartBox::HandleReceivedPosition` arms `ConstrainTo` @0x00454272 every time. acdream's far snap is a canonical Runtime placement that can decline for reasons retail has no analogue for, and this row records the complete residual. **(1) An outcome that never reached the engine is a `store_position`; one that did is not.** Retail's `SetPositionInternal` @0x00515BD0 has exactly two shapes and acdream now represents both (**corrected 2026-08-04 at the delta review, which found the first version of this row asserting — wrongly — that no acdream non-commit outcome could represent the second**). STORES, because the resolve never ran: `Refused` (the pre-flight declined the destination), `Contention` (another authority owns the operation, or the Setup/world-frame preparation is retryable), `RejectedPreparation` (`RejectedAuthority`/`InvalidData` — preparation refused before anything was submitted), and `NotApplicable`. For those `ApplyAcceptedRemoteFarSnap` writes the accepted destination pose to the canonical body, exactly as retail commits it on the no-transition branch — `prepare_to_leave_visibility` @0x00515CDA, `store_position` @0x00515CE2, `GotoLostCell` @0x00515CF2, `return 0` @0x00515D07 — so the remote keeps tracking the server at 5-10 Hz, at the destination, with no resolved cell; retail would additionally have hidden it until cell load, which is AP-136's scope, not this one. DOES NOT STORE, because the resolve DID run and refused: `RejectedByPlacement` (`PhysicsEngine.SetPosition` returned a non-Ok error, acdream's port of retail's `CheckPositionInternal == 0` @0x00515C85/@0x00515CD5 and `curr_cell == 0` @0x00515C8F/@0x00515CB2, neither of which stores; or authority displaced after the engine ran, which includes the `CommitCanonical`-already-settled shape) and `Deferred` (Core parked, and `ParkDeferred` has ALREADY snapped the body to the parked result — the accepted destination for the pre-sweep park, the collision-settled `spherePath.CurPos` for the post-sweep one — which `RestoreParkWithdrawal` deliberately leaves alone). **(2) A quiescence park a far snap can provoke is now restorable at the source, not refused by a pre-flight.** **Rewritten 2026-08-04 at the delta review.** `CanAttemptDestination` (service window + Core's own `IsCollisionPrefixQuiescing`) reads ONE prefix, the destination's, and stays as an optimisation. It cannot be the correctness mechanism: Core's `PlacementTouchesPrefix` also matches the request's `CurrentCellId` (see the round-3 measurement below for what that arm actually names), and `ResultTouchesPrefix` scans every `QueriedCellIds` entry, a sweep footprint that spans NEIGHBOUR landblocks (`CellTransit.AddOutsideCell` re-derives the block id from the global lcoord and has no same-block filter) and does not EXIST until the sweep has run. Worse, the post-sweep check is `result.IsSuccessful && TryGetBlockingQuiescence(result, …)` and sits ahead of the restorable `result.IsDeferred` park, so a healthy about-to-COMMIT far snap near a seam was rewritten to `DeferredCell` and parked non-restorably. The fix is in `SubmitPreparedPlacementCore`: both quiescence parks are restorable, and `ParkDeferred` decides safety on the cell it will actually restore into — see AP-136 for the exact predicate and for why it does not re-open the retirement stall AP-136's blanket scoping was protecting against. On a FIRST submit the `CurrentCellId` half of `PlacementTouchesPrefix` is NOT the "source landblock a far snap is leaving": both accepted-Position callers commit the accepted wire cell to `record.FullCellId` before submitting (the graphical remote path through `LiveEntityRuntime.RebucketLiveEntity` in its shared prologue, route 2 through the merge), so that arm names the destination — measured 2026-08-04 at round 3. **Scoped at round 4 (D5): that is a first-submit property only, and the arm is live rather than dead code.** A RETAINED operation re-submits from its own cadence pump with no fresh merge (both drives re-read `record.FullCellId` at submit), and the surviving non-Position rebucket writers (the projection materializer, the equipped-child renderer — C4 route 4b-3 deleted the third shipped writer, `RemoteTeleportController`'s rollback) can rebucket it to a third landblock, so a retry can genuinely name a third landblock — which `CanAttemptDestination`'s own doc already said and the two summaries elsewhere contradicted. **(3) The leash is not armed through a superseded incarnation.** Retail arms unconditionally on the nonzero return; acdream re-validates position ownership after the placement (the receipt is published synchronously and the projection sink can replace or delete the incarnation from inside it) and returns without arming if the owner moved. Both remote arms now run that check BEFORE their arming call — the player arm used to arm first, the NPC arm second, and one of the two mirror images had to be wrong | `src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs` (`RuntimeRemotePlacementExecutionStatus` + `StoresAcceptedDestination`, `ApplyAcceptedRemoteFarSnap`, `StoreAcceptedDestinationPose`, `Advance`'s window-drop path, `CanAttemptDestination`, `SubmitAndResolve`); `src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs` (`ParkDeferred`'s post-snap restorable decision and the two `SubmitPreparedPlacementCore` quiescence parks); `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (both arms' re-validate-then-arm order) | The alternative to (1) is the shipped pre-review state: an emptied interpolation queue plus a stale body pose, i.e. a frozen remote that the next packet reproduces identically, since nothing about a refusal reason changes at packet cadence. That is strictly further from retail than either the deleted legacy block (which always tracked) or retail itself. The alternative tried and rejected in between — storing on EVERY non-commit outcome — is worse still in the other direction: it teleports the canonical body into a destination the engine's own sweep just refused, and overwrites a freshly settled pose (contact plane, step-down) whenever `CommitCanonical` landed and only the projection ownership was displaced. The alternative to (2) — keeping the pre-flight as the correctness mechanism and widening it — is structurally impossible, because the swept footprint half of Core's predicate does not exist until the sweep has run; the alternative of leaving the parks non-restorable strands the remote outright. The alternative to (3) — arming a leash on a host that is no longer the entity's canonical position owner — is a write through superseded state, the exact class the re-validation exists to prevent, and retail has no superseded-incarnation state for its unconditional arm to arbitrate | A remote whose destination this host cannot place into keeps moving and rendering but does not become collidable or cell-resident until a later packet commits — it can be walked through at range. Bounded by the 5-10 Hz packet stream and by how long the destination stays unpublished/quiescing. A remote whose destination the ENGINE refuses, or whose commit was displaced, keeps its last resolved pose for that packet instead of tracking — retail-exact, but it means a remote can look one packet stale near geometry it cannot be placed into. A quiescence park whose blocking prefix is a swept neighbour re-shows the entity immediately at the destination rather than hiding it until cell load (AP-136's own residual, now reachable through this path and through route 2's local-player corrections). **C4 route 4b-3 adds a second producer of the visible-without-collision shape in item (1)'s storing list**: the teleport arm inherits the identical store-and-stay-visible residual for the same reasons — a remote that teleports into a non-published landblock and stands still is visible but not collidable until a later packet commits. No new machinery; the retirement path is the same #309. A superseded incarnation's leash is left unarmed for one packet; the replacement incarnation arms its own on its next accepted Position. Retire (1) by making the far arm's failure path open retail's lost-cell registration instead of a bare pose write, which is issue #309's territory (the park must survive cancellation first) | `CPhysicsObj::MoveOrTeleport` 0x00516330 (@0x005163D9, @0x005163E8); `CPhysicsObj::SetPositionSimple` @0x005162B0 (flags `0x1012` @0x005162C4); `CPhysicsObj::SetPositionInternal` @0x00515BD0 (@0x00515C1D, @0x00515CDA, @0x00515CE2, @0x00515CF2, @0x00515CB2, @0x00515CD5, @0x00515D07); `SmartBox::HandleReceivedPosition` @0x00453FD0 (@0x00454254, @0x00454272) | | AP-139 | **Filed 2026-08-04 (Bug B).** The remote tick clears its InterpolationManager queue on the LANDING edge — retail’s own `set_on_walkable(1)` transition, the same edge HitGround fires from. Retail has no such clear on a ground or contact edge: its only queue teardown outside a completed walk is `PositionManager::StopInterpolating` from `CPhysicsObj::teleport_hook` @0x00514EFD and the `InterpolationManager::UseTime` @0x00555f20 stall/autonomy blips. The clear is carried over unchanged in intent from the deleted hand-rolled landing block (#184, 2026-07-07), which hung it on a hand-rolled `Airborne && IsOnGround && Velocity.Z <= 0` test that also fired on a steep (non-walkable) contact; Bug B re-derived the edge without changing the behaviour it was written for | `src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs` (the SetPositionInternal commit block); the packet-side twin lives in `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition`, the player-remote landing snap) | A contact-free arc never enqueues — route 4a's airborne no-op writes nothing at all — so anything still queued when the body lands is a pre-arc waypoint, and the first catch-up after touchdown would otherwise walk the body backward toward it | A remote that regains contact while a legitimately fresh waypoint is queued loses one correction and re-acquires it on the next accepted Position (~5-10 Hz). A body that repeatedly loses and regains contact (a bounce chain down a rough face) clears the queue once per bounce. Retire when the arc itself feeds the queue, at which point the pre-arc waypoints are no longer stale | `CPhysicsObj::teleport_hook @ 0x00514ED0` (`StopInterpolating` @0x00514EFD); `InterpolationManager::UseTime @ 0x00555f20`; `CPhysicsObj::SetPositionInternal @ 0x00515330` | ## 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` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — 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) diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index fa3a106c..83d6e26e 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -105,7 +105,6 @@ internal sealed record LivePresentationResult( EquippedChildRenderController EquippedChildren, EntityEffectController EntityEffects, LiveEntityPresentationController Presentation, - RemoteTeleportController RemoteTeleport, WbDrawDispatcher? DrawDispatcher, RetailSelectionScene SelectionScene, WorldSelectionQuery SelectionQuery, @@ -634,26 +633,6 @@ internal sealed class LivePresentationCompositionPhase d.EntityObjects.Objects, liveEntities, d.PhysicsEngine.ShadowObjects)); - var remoteShadowPlacement = new RemoteShadowPlacementSynchronizer( - d.RemotePhysicsUpdater, - d.WorldOrigin); - var remoteTeleportPresentation = - new RemoteTeleportPlacementPresentation(presentationLease.Resource); - var remoteTeleportLease = scope.Acquire( - "remote teleport", - () => new RemoteTeleportController( - d.PhysicsEngine, - liveEntities, - d.MotionBindings.GetSetupCylinder, - d.WorldOrigin.CellLocalForSeed, - remoteShadowPlacement.Sync, - remoteTeleportPresentation.Complete, - remoteTeleportPresentation.Begin, - getMoverPvpState: guid => - EntityCollisionFlagsExt.ResolveMoverPvpState( - d.EntityObjects.Objects, - guid)), - static value => value.Dispose()); bindings.BindProjectionPoseReady( equippedLease.Resource, lightsLease.Resource.OnAttachedPoseReady); @@ -697,7 +676,6 @@ internal sealed class LivePresentationCompositionPhase equippedLease, entityEffects, presentationLease, - remoteTeleportLease, selectionInteractionSource, bindings, scope, @@ -747,7 +725,6 @@ internal sealed class LivePresentationCompositionPhase CompositionAcquisitionScope.CompositionAcquisitionLease equippedLease, EntityEffectController entityEffects, CompositionAcquisitionScope.CompositionAcquisitionLease presentationLease, - CompositionAcquisitionScope.CompositionAcquisitionLease remoteTeleportLease, DeferredSelectionInteractionSource selectionInteractionSource, LivePresentationRuntimeBindings bindings, CompositionAcquisitionScope scope, @@ -1227,7 +1204,6 @@ internal sealed class LivePresentationCompositionPhase equippedLease.Resource, entityEffects, presentationLease.Resource, - remoteTeleportLease.Resource, dispatcherLease.Resource, selectionScene, selectionQuery, @@ -1267,7 +1243,6 @@ internal sealed class LivePresentationCompositionPhase lightsLease.Transfer(); equippedLease.Transfer(); presentationLease.Transfer(); - remoteTeleportLease.Transfer(); dispatcherLease.Transfer(); retainedGameplayLease?.Transfer(); paperdollLease?.Transfer(); diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 58614a89..b665fc51 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -480,7 +480,6 @@ internal sealed class SessionPlayerCompositionPhase live.LiveEntities, live.Presentation, live.EntityEffects, - live.RemoteTeleport, live.SelectionInteractions, d.Actions.Selection, d.AnimatedEntities, @@ -645,7 +644,6 @@ internal sealed class SessionPlayerCompositionPhase live.Lights, live.EquippedChildren, live.ProjectileController, - live.RemoteTeleport, d.AnimatedEntities, d.RemoteMovementObservations, d.RemotePhysicsUpdater, @@ -981,7 +979,6 @@ internal sealed class SessionPlayerCompositionPhase liveness, networkUpdates, hydration, - live.RemoteTeleport, live.EntityEffects, content.AnimationHookFrames, live.Presentation, diff --git a/src/AcDream.App/Net/LiveSessionResetManifest.cs b/src/AcDream.App/Net/LiveSessionResetManifest.cs index 2cb99001..2540d1c3 100644 --- a/src/AcDream.App/Net/LiveSessionResetManifest.cs +++ b/src/AcDream.App/Net/LiveSessionResetManifest.cs @@ -25,7 +25,6 @@ internal sealed class LiveSessionResetBindings public required Action RuntimeGeneration { get; init; } public required Action SessionIdentityPresentation { get; init; } - public required Action RemoteTeleport { get; init; } public required Action NetworkEffects { get; init; } public required Action AnimationHookFrames { get; init; } public required Action LivePresentation { get; init; } @@ -65,7 +64,6 @@ internal static class LiveSessionResetManifest new( "session identity presentation", bindings.SessionIdentityPresentation), - new("remote teleport", bindings.RemoteTeleport), // F754/F755 can precede CreateObject, so pending network effects // must clear even when no LiveEntityRecord was constructed. new("network effects", bindings.NetworkEffects), diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 3dc0c2b4..5e5b48f7 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -79,7 +79,6 @@ internal sealed record LiveSessionWorldRuntime( LiveEntityLivenessController Liveness, LiveEntityNetworkUpdateController NetworkUpdates, LiveEntityHydrationController Hydration, - RemoteTeleportController RemoteTeleport, EntityEffectController EntityEffects, AnimationHookFrameQueue AnimationHookFrames, LiveEntityPresentationController Presentation, @@ -193,7 +192,6 @@ internal sealed class LiveSessionRuntimeFactory RuntimeGeneration = generation => _domain.Runtime.ResetGeneration(generation, resetHost), SessionIdentityPresentation = ResetIdentityPresentation, - RemoteTeleport = _world.RemoteTeleport.Clear, NetworkEffects = _world.EntityEffects.ClearNetworkState, AnimationHookFrames = _world.AnimationHookFrames.Clear, LivePresentation = _world.Presentation.Clear, diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 8cab4a47..5581a28c 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -41,7 +41,6 @@ internal sealed class LiveEntityNetworkUpdateController private readonly LiveEntityLightController _liveEntityLights; private readonly EquippedChildRenderController _equippedChildRenderer; private readonly ProjectileController _projectileController; - private readonly RemoteTeleportController _remoteTeleportController; private readonly LiveEntityAnimationRuntimeView _animatedEntities; private readonly RemoteMovementObservationTracker _remoteMovementObservations; private readonly RemotePhysicsUpdater _remotePhysicsUpdater; @@ -92,7 +91,6 @@ internal sealed class LiveEntityNetworkUpdateController LiveEntityLightController liveEntityLights, EquippedChildRenderController equippedChildRenderer, ProjectileController projectileController, - RemoteTeleportController remoteTeleportController, LiveEntityAnimationRuntimeView animatedEntities, RemoteMovementObservationTracker remoteMovementObservations, RemotePhysicsUpdater remotePhysicsUpdater, @@ -124,7 +122,6 @@ internal sealed class LiveEntityNetworkUpdateController _liveEntityLights = liveEntityLights ?? throw new ArgumentNullException(nameof(liveEntityLights)); _equippedChildRenderer = equippedChildRenderer ?? throw new ArgumentNullException(nameof(equippedChildRenderer)); _projectileController = projectileController ?? throw new ArgumentNullException(nameof(projectileController)); - _remoteTeleportController = remoteTeleportController ?? throw new ArgumentNullException(nameof(remoteTeleportController)); _animatedEntities = animatedEntities ?? throw new ArgumentNullException(nameof(animatedEntities)); _remoteMovementObservations = remoteMovementObservations ?? throw new ArgumentNullException(nameof(remoteMovementObservations)); _remotePhysicsUpdater = remotePhysicsUpdater ?? throw new ArgumentNullException(nameof(remotePhysicsUpdater)); @@ -172,8 +169,8 @@ internal sealed class LiveEntityNetworkUpdateController /// silently refuses every action animation — a spawned-standing monster's /// attack swings never played until it first moved (the [MT-FAIL] /// Falling-substitution spam was the same body state surfacing through - /// apply_interpreted_movement). Mirrors - /// 's commit with spawn-shaped + /// apply_interpreted_movement). Mirrors the canonical Runtime + /// placement commit (RuntimeSetPositionState) with spawn-shaped /// inputs (no prior contact). /// private void SeedRemoteSpawnPlacement( @@ -234,27 +231,68 @@ internal sealed class LiveEntityNetworkUpdateController remote.Airborne = !remote.Body.OnWalkable; } + /// + /// R6/R2 fix round (2026-08-04). Two independent fixes: + /// + /// + /// R6. Previously re-resolved the RemoteMotion/host BY + /// GUID instead of using the rmState the caller already holds and + /// hands to . Every action was + /// null-conditional (remote?./host?.), so a resolution + /// mismatch silently no-op'd all six actions while + /// still returned true — + /// and PhysicsDiagnostics.LogRemoteTeleport printed + /// hookRan=True for a hook that did nothing. + /// is now the caller's own rmState directly (never null, no + /// lookup, no mismatch risk) and host comes from + /// — the SAME bound reference, not a + /// second independent resolution. A null host is now a genuine "the + /// manager doesn't exist yet" case (retail's own "each guarded on the + /// manager existing"), not a resolution bug. + /// + /// + /// + /// R2. ReportCollisionEnd — retail + /// report_collision_end(this, 1) @0x00514F31 → @0x00514620, a + /// force-end-all of the collision TABLE with bidirectional + /// DoCollisionEnd — used to call ShadowObjects.Suspend, + /// which ports a DIFFERENT retail function + /// (remove_shadows_from_cells) that teleport_hook never + /// calls, and which retail does NOT do at this call site (a real, if + /// frames-scale, added divergence — the shadow un-suspends again inside + /// the arm tail's own LiveEntityShadowPublisher.TryPublishRemote, + /// exactly like every other placement, so nothing else needs to + /// compensate for dropping it here). Now routes through + /// , which + /// forwards to RuntimeCollisionReportingState.LeaveWorld — the + /// existing, unreached-until-now, exact port of this retail call. + /// + /// + /// + /// Round-2 architecture review B2 — do NOT record this as a closed + /// observable delta. The plumbing is retail-correct, but + /// IRuntimeCollisionReportObserver has ZERO production + /// implementations, so retail's bidirectional DoCollisionEnd half + /// still reaches no gameplay consumer. What this fix closes is the wrong + /// retail-function binding; what stays open is that nobody listens. That + /// remains true until an observer ships. + /// + /// private bool RunRemoteTeleportHook( - uint serverGuid, - uint localEntityId, + RuntimeEntityRecord canonical, + RemoteMotion remote, Func isCurrent) { - _liveEntities.TryGetRemoteMotionRuntime( - serverGuid, - out IRuntimeRemoteMotion? remoteRuntime); - RemoteMotion? remote = remoteRuntime as RemoteMotion; - EntityPhysicsHost? host = - _liveEntities.TryGetPhysicsHost(serverGuid, out var registered) - ? registered as EntityPhysicsHost - : null; + EntityPhysicsHost? host = remote.Host; return RemoteTeleportHook.Execute( new RemoteTeleportHookActions( - CancelMoveTo: error => remote?.Movement.CancelMoveTo(error), + CancelMoveTo: error => remote.Movement.CancelMoveTo(error), UnStick: () => host?.PositionManager.UnStick(), - StopInterpolating: () => remote?.Interp.Clear(), + StopInterpolating: () => remote.Interp.Clear(), UnConstrain: () => host?.PositionManager.UnConstrain(), NotifyTeleported: () => host?.NotifyTeleported(), - ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)), + ReportCollisionEnd: () => + _liveEntities.ForceEndCollisionReporting(canonical)), isCurrent); } public void ApplySameGeneration( @@ -845,17 +883,16 @@ internal sealed class LiveEntityNetworkUpdateController /// physics-controller distance, and (never a /// fabricated Vector3.Zero) when no controller exists yet, which /// makes Runtime decline. Callers must only invoke this for a genuinely - /// remote (never local-player) entity whose - /// remotePlacementRequired gate is already false. + /// remote (never local-player) entity. /// /// - /// C4 route 4b-2 review fix — this comment used to end "and leaves the - /// legacy path untouched". There is no legacy path left: the duplicated - /// App-side near/far blocks were deleted with this slice, and a declined - /// classification now takes the stated UnroutedCatchUp policy - /// (AP-137) through ApplyRemoteContactRouting's default arm. The + /// There is no legacy path: the duplicated App-side near/far blocks were + /// deleted at C4 route 4b-2, and a declined classification takes the + /// stated UnroutedCatchUp policy (AP-137) through + /// ApplyRemoteContactRouting's default arm. The /// return is still "Runtime has no opinion", never - /// "rejected"; what changed is what the caller does with it. + /// "rejected"; what the caller does with it is + /// ApplyRemoteContactRouting's job, not this method's. /// /// private RuntimeAuthoritativePositionRoute? ClassifyRemoteAcceptedPosition( @@ -890,16 +927,13 @@ internal sealed class LiveEntityNetworkUpdateController /// /// /// - /// C4 route 4b-2 review fix — this comment used to end "Every other - /// classification keeps the pre-existing write, unchanged, until route - /// 4b", which is now false in both halves. The gate is still - /// OwnsSteadyState, so the FAR snap (4b-2's own arm) DOES take the - /// wire-pose write here even though it goes on to place canonically. That - /// is deliberate and is not a second writer in the route 2 sense: the far - /// arm's tail re-syncs the render entity from the RESOLVED body + /// The gate is OwnsSteadyState — unchanged by C4 routes 4b-2 or + /// 4b-3. The far snap AND the teleport arm both DO take the wire-pose + /// write here even though each goes on to place canonically. That is + /// deliberate and is not a second writer in the route 2 sense: both + /// arms' tails re-sync the render entity from the RESOLVED body /// afterwards, so this write only covers the window before the placement - /// commits, exactly as it did before the slice. Route 4b-3 revisits the - /// gate when it takes the cell-less half. + /// commits. /// /// /// @@ -939,10 +973,12 @@ internal sealed class LiveEntityNetworkUpdateController /// Which arm of the remote contact routing claimed a packet. The /// value is the seam's observable outcome, asserted by the acceptance - /// tests; production only distinguishes - /// (which alone can be - /// re-entrant), but the finer result is what makes the PRECEDENCE and the - /// arm selection testable and must not be collapsed to a bool. + /// tests; production distinguishes + /// and (C4 route 4b-3) + /// — the two arms that + /// execute a canonical placement and are therefore re-entrant — but the + /// finer result is what makes the PRECEDENCE and the arm selection + /// testable and must not be collapsed to a bool. internal enum RemoteContactArm : byte { /// The body was in free flight — NOT in contact with any @@ -971,10 +1007,19 @@ internal sealed class LiveEntityNetworkUpdateController /// through the canonical Runtime placement owner. FarSnapPlacement, - /// The acdream-only leftover set (null, Rejected*, the - /// cell-less SetPosition half 4b-3 will own). See - /// for - /// the stated policy. + /// + /// C4 route 4b-3: retail's teleport/cell-less branch — + /// teleport_hook @0x00514ED0 then SetPosition + /// @0x00516420 — executed through the canonical Runtime placement + /// owner. Decided AHEAD of the contact test (D5): a teleport packet + /// never takes regardless of wire or body + /// contact. + /// + TeleportPlacement, + + /// The acdream-only leftover set (null, Rejected*). + /// See + /// for the stated policy. UnroutedCatchUp, } @@ -1024,16 +1069,18 @@ internal sealed class LiveEntityNetworkUpdateController /// /// C4 route 4b-2 added and /// deleted the two duplicated App-side near/far blocks that used to follow - /// this call. The far arm is the only re-entrant one — a canonical - /// placement publishes its Place receipt synchronously, and a - /// non-commit outcome publishes a cancellation receipt just as - /// synchronously, and the production placement-projection sink can delete - /// or replace the incarnation from inside either — so a caller MUST - /// re-validate position ownership after this returns - /// , on EVERY placement - /// status, before writing anything else for the packet. That includes the - /// ConstrainTo leash: both arms therefore run the re-validation - /// FIRST and arm second (see AP-138). + /// this call; C4 route 4b-3 added + /// , decided AHEAD of + /// everything else (see below). The far and teleport arms are the only + /// re-entrant ones — a canonical placement publishes its + /// Place receipt synchronously, and a non-commit outcome publishes + /// a cancellation receipt just as synchronously, and the production + /// placement-projection sink can delete or replace the incarnation from + /// inside either — so a caller MUST re-validate position ownership after + /// this returns either arm, on EVERY placement status, before writing + /// anything else for the packet. That includes the ConstrainTo + /// leash: every caller therefore runs the re-validation FIRST and arms + /// second (see AP-138). /// /// internal static RemoteContactRouting ApplyRemoteContactRouting( @@ -1043,11 +1090,61 @@ internal sealed class LiveEntityNetworkUpdateController RuntimeAuthoritativePositionRoute? route, System.Numerics.Vector3 worldPos, System.Numerics.Quaternion rotation, - bool willBeDrTicked) + bool willBeDrTicked, + Func runTeleportHook) { ArgumentNullException.ThrowIfNull(placementDrive); ArgumentNullException.ThrowIfNull(canonical); ArgumentNullException.ThrowIfNull(remote); + ArgumentNullException.ThrowIfNull(runTeleportHook); + + // C4 route 4b-3 (D5): retail decides the teleport/cell-less branch + // BEFORE reading arg4 (the wire contact bit) — + // `MoveOrTeleport`'s @0x00516375-@0x00516386 test runs before + // @0x0051638E. A teleport-classified packet therefore places + // unconditionally, ahead of the free-flight carve-out below — an + // airborne-body teleport packet places, it does not AirborneSnap. + // teleport_hook @0x00514ED0 (the caller-supplied delegate) runs + // BEFORE the placement, regardless of what the placement then + // yields, exactly like retail's ordering + // (@0x005163EF before @0x00516420). + if (RuntimeRemoteTeleportPosition.OwnsTeleportPlacement(route)) + { + // R7 (2026-08-04): hookRan is consumed ONLY by the probe below — + // routing proceeds to the placement regardless of its value. + // This is retail-faithful, not a dropped result: retail has no + // currency concept and runs teleport_hook unconditionally before + // ever knowing the placement outcome (@0x005163EF is called + // regardless of what @0x00516420 later yields). + bool hookRan = runTeleportHook(); + RuntimeRemotePlacementExecutionStatus teleportStatus = + placementDrive.ApplyAcceptedRemoteTeleport( + canonical, + remote, + route!.Value); + // Live-execution proof (process rule 5): confirms the arm + // actually ran rather than inferring it from a clean-looking + // session. TEMPORARY — strip with ACDREAM_PROBE_REMOTE_TELEPORT. + // + // A6 fix round (2026-08-04): guarded at the call site now — the + // probe's own self-guard inside LogRemoteTeleport did not stop + // teleportStatus.ToString() from being evaluated (and allocated) + // on every teleport regardless of whether the probe was enabled. + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteTeleportEnabled) + { + AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteTeleport( + canonical.ServerGuid, + cause: route.Value.Authority.TeleportAdvanced + ? "teleport-ts" + : "cellless", + hookRan, + teleportStatus.ToString()); + } + return new RemoteContactRouting( + RemoteContactArm.TeleportPlacement, + teleportStatus); + } + // Bug B (2026-08-04): stamp the GUID that any [remote-slide-*] line // emitted from inside this synchronous routing window belongs to — // ApplyInterpolate (blip producer Candidate 1) has no GUID of its own. @@ -1071,11 +1168,12 @@ internal sealed class LiveEntityNetworkUpdateController // cadence instead of interpolated. // // `Airborne` itself is deliberately NOT re-derived from CONTACT: it has - // five writers, all spelling `!Body.OnWalkable`, and the per-tick - // updater's ground-clamp branch plus - // `RemoteTeleportPlacementTests.Apply_PendingGroundToSteepContact_…` - // both depend on the walkability reading. Only the two ROUTING gates - // move (this one and `OnPosition`'s player-remote landing block). + // four writers (C4 route 4b-3 retired the fifth, the deleted + // `RemoteTeleportPlacement.Apply` — its derivation is now the + // canonical placement commit's, already on the list), all spelling + // `!Body.OnWalkable`, and the per-tick updater's ground-clamp branch + // depends on the walkability reading. Only the two ROUTING gates move + // (this one and `OnPosition`'s player-remote landing block). if (!remote.Body.InContact) { // Verbatim from the pre-4a branch, queue deliberately NOT @@ -1161,6 +1259,121 @@ internal sealed class LiveEntityNetworkUpdateController } } + /// + /// C4 route 4b-3 (D2): retail's arg4 != 0/== 0 return-0 + /// shape, applied to the two acdream-only leftover classifications that + /// remain wire-airborne after IsAirborneNoOperation + /// (NoPositionOperation, handled by each caller's own early + /// return before this is ever reached) and the teleport arm (D5 routes + /// it ahead of every contact carve-out — a teleport-classified route + /// must never be caught here, which is why every caller passes + /// isTeleportRoute rather than re-deriving it) — null during the + /// login window, or RejectedAuthority/RejectedData. + /// AP-135's bookkeeping (the server-cell adopt and the + /// LastServerPos/LastServerPosTime sample) is 4a-owned + /// free-fall-sweep/first-grounded-velocity state, not a retail + /// CPhysicsObj field, so it stays; nothing else does — no body + /// write, no queue write, no render write, no leash arm. + /// + /// + /// Fix round (2026-08-04, R1/A7): shared by both remote branches so they + /// cannot re-diverge on this shape the way they did before this round — + /// the player arm had it, the NPC arm did not, so a wire-airborne + /// null/Rejected* NPC packet fell through to + /// 's own free-flight carve-out + /// and received a body write, an arm, and a render/shadow publish + /// retail's return 0 never produces. The player arm's own call + /// site sets redundantly (it already wrote + /// the identical value unconditionally before reaching this point); the + /// NPC arm's call site is where the write is actually load-bearing. + /// + /// + private static void ApplyWireAirborneLeftoverBookkeeping( + RemoteMotion remote, + uint wireCellId, + System.Numerics.Vector3 worldPos, + double nowSec) + { + ArgumentNullException.ThrowIfNull(remote); + remote.CellId = wireCellId; + remote.LastServerPos = worldPos; + remote.LastServerPosTime = nowSec; + } + + /// + /// C4 route 4b-3 fix round (2026-08-04). The routing-decision-plus- + /// currency sequence every remote arm tail performs identically around + /// : run the routing (which + /// itself decides D5's teleport-before-contact ordering and D3's hook + /// timing), then re-validate ownership before writing anything further + /// for this packet (R5's guard-before-arm shape — the far and teleport + /// arms are re-entrant, because their canonical placement publishes its + /// Place or cancellation receipt synchronously and the production + /// projection sink can delete or replace this incarnation from inside + /// either). + /// + /// + /// Extracted after two independent reviews found the three hand-written + /// App-layer copies of this sequence (the player arm's teleport + /// dispatch, the player arm's grounded-routing dispatch, the NPC arm's + /// single dispatch) had begun to drift in ways this class's own + /// duplication made invisible: A2/R3 is precisely the player copy's + /// teleport block returning before its synth-velocity code while the + /// NPC copy had no such boundary at all, and A1 is + /// ToConstraintArm having been written against only the player + /// copy's reachable arm set. One implementation now backs every call + /// site so a future edit cannot silently narrow or widen one copy's + /// guard relative to the others'. + /// + /// + /// + /// Returns when nothing further should be + /// written for this packet — the currency guard tripped — and the + /// caller must return without arming, adopting the wire cell, or + /// publishing the render entity/collision shadow. Does NOT itself arm + /// the leash ('s corrected mapping is + /// applied by the caller, which also needs it for the NPC arm's + /// sticky-suppressed default-arm case where this method is never + /// called) and does NOT itself gate the synth-velocity install (NPC- + /// only code with no counterpart here — see the NPC arm's own + /// isTeleportRoute guard). + /// + /// + private RemoteContactRouting? RunRemoteArmTail( + RuntimeEntityRecord canonical, + LiveEntityRecord positionRecord, + RemoteMotion remote, + RuntimeAuthoritativePositionRoute? route, + uint guid, + System.Numerics.Vector3 worldPos, + System.Numerics.Quaternion rotation, + Func isCurrentPositionOwner) + { + ArgumentNullException.ThrowIfNull(isCurrentPositionOwner); + RemoteContactRouting routing = ApplyRemoteContactRouting( + _remotePlacementDrive, + canonical, + remote, + route, + worldPos, + rotation, + willBeDrTicked: WillAdvanceRemoteMotion(guid, remote), + runTeleportHook: () => RunRemoteTeleportHook( + canonical, + remote, + isCurrentPositionOwner)); + + if ((routing.Arm is RemoteContactArm.FarSnapPlacement + or RemoteContactArm.TeleportPlacement) + && (!isCurrentPositionOwner() + || !ReferenceEquals(positionRecord.RemoteMotionRuntime, remote))) + { + return null; + } + + return routing; + } + /// /// C4 route 4b-2: the NPC-remote arm's post-routing wire-cell adoption, /// extracted so its ONE suppression rule is exercised by production and by @@ -1169,11 +1382,12 @@ internal sealed class LiveEntityNetworkUpdateController /// /// writes THROUGH to the canonical /// FullCellId (RuntimePhysicsState.CommitCanonicalCell). - /// After a far snap the canonical placement is the cell authority — retail + /// After a far snap OR a teleport (C4 route 4b-3, D6) the canonical + /// placement is the cell authority — retail /// CPhysicsObj::SetPositionInternal (0x00515BD0) resolves the /// destination cell through AdjustPosition/set_cell and /// nothing writes the wire cell over it afterwards — so this write is - /// suppressed for that arm alone. Unlike the player arm, whose identical + /// suppressed for both arms. Unlike the player arm, whose identical /// write sits BEFORE its routing, the NPC one sits after; leaving it /// unguarded would discard a resolved cell that differs from the wire /// cell. Every other arm performs no placement, so the wire cell is still @@ -1234,12 +1448,64 @@ internal sealed class LiveEntityNetworkUpdateController uint wireCellId) { ArgumentNullException.ThrowIfNull(remote); - if (arm is RemoteContactArm.FarSnapPlacement) + if (arm is RemoteContactArm.FarSnapPlacement + or RemoteContactArm.TeleportPlacement) return false; remote.CellId = wireCellId; return true; } + /// + /// C4 route 4b-3 (D4): translates this App-layer routing outcome into the + /// Runtime arm value + /// + /// consumes — the single post-operation ConstrainTo site. + /// + /// + /// A1 fix round (2026-08-04): this table previously mapped + /// to + /// — + /// the one value that never arms — on the claim that "production never + /// calls this with that arm". That claim was false on the NPC branch: + /// 's free-flight carve-out + /// (!remote.Body.InContact) returns AirborneSnap for ANY + /// non-teleport classification whenever the body lacks a contact + /// plane — including an ordinary landing packet (wire IS grounded, so + /// retail's arg4 != 0, so retail's MoveOrTeleport returns + /// nonzero and arms @0x00454272 regardless of the body's own contact + /// state, which is an acdream-only concept for snap-vs-interpolate + /// selection, not retail's arming predicate). Mapping it to + /// AirborneNoOperation silently dropped the leash's per-packet + /// re-anchor to zero for a creature knocked off a ledge. It now maps to + /// — the + /// SAME arming value the player arm's own LANDING TRANSITION block + /// already uses explicitly for the identical scenario (grounded wire, + /// body not in contact). Retail's true airborne no-op + /// (arg4 == 0, acdream's has no + /// dedicated case for it because it is never routed through + /// ApplyRemoteContactRouting at all — both callers return before + /// reaching it, via IsAirborneNoOperation/D2) is the only case + /// this table cannot express, which is why the switch stays total via a + /// throwing default rather than a silent zero-arm fallback. + /// + /// + private static RuntimeRemoteAcceptedPositionArm ToConstraintArm( + RemoteContactArm arm) => arm switch + { + RemoteContactArm.TeleportPlacement => + RuntimeRemoteAcceptedPositionArm.TeleportPlacement, + RemoteContactArm.FarSnapPlacement => + RuntimeRemoteAcceptedPositionArm.FarSnapPlacement, + RemoteContactArm.SteadyStateInterpolate => + RuntimeRemoteAcceptedPositionArm.NearInterpolate, + RemoteContactArm.AirborneSnap => + RuntimeRemoteAcceptedPositionArm.NearInterpolate, + RemoteContactArm.UnroutedCatchUp => + RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp, + _ => throw new ArgumentOutOfRangeException( + nameof(arm), arm, "Unhandled RemoteContactArm in ToConstraintArm."), + }; + /// /// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate from remote jumps. /// The payload seeds the world-space launch velocity and angular velocity. @@ -1714,22 +1980,6 @@ internal sealed class LiveEntityNetworkUpdateController : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); _movementTruthDiagnostics.OnServerEcho(update, worldPos); - bool remoteHardTeleport = update.Guid != _playerServerGuid - && timestamps.TeleportHookRequired; - bool remotePlacementRequired = update.Guid != _playerServerGuid - && (remoteHardTeleport - || _remoteTeleportController?.HasPending(update.Guid) == true); - if (remoteHardTeleport) - { - if (!RunRemoteTeleportHook( - update.Guid, - entity.Id, - () => IsCurrentPositionOwner(entity))) - { - return; - } - } - // Missiles reconcile the same predicted PhysicsBody in place. The // timestamp gate above already rejected stale corrections; returning // here prevents the generic remote locomotion path from allocating a @@ -1768,16 +2018,15 @@ internal sealed class LiveEntityNetworkUpdateController // whose accepted Position resolves to NoPositionOperation (retail's // airborne no-op — writes nothing at all) or Interpolate (retail's // near InterpolateTo queue — no direct body write here) never - // receives it. remotePlacementRequired guarantees the classifier's - // teleport (SetPosition) disposition never reaches here — that stays - // route 4b-3. The local player never reaches this generic-remote code - // path at all. C4 route 4b-2 additionally routes the >=96 m far snap - // through the canonical Runtime placement owner; a cell-less remote, a - // rejected authority or payload, and "no classification at all" take - // the stated UnroutedCatchUp policy - // (RuntimeRemoteFarSnapPosition.ResolveArm) until 4b-3. + // receives it. The local player never reaches this generic-remote + // code path at all. C4 route 4b-2 routes the >=96 m far snap and C4 + // route 4b-3 routes the teleport/cell-less classification through + // the canonical Runtime placement owner (ApplyRemoteContactRouting); + // a rejected authority or payload, and "no classification at all", + // take the stated UnroutedCatchUp policy + // (RuntimeRemoteFarSnapPosition.ResolveArm). RuntimeAuthoritativePositionRoute? earlyRemoteRoute = - update.Guid != _playerServerGuid && !remotePlacementRequired + update.Guid != _playerServerGuid ? ClassifyRemoteAcceptedPosition( update, acceptedPositionCanonical, @@ -1795,9 +2044,16 @@ internal sealed class LiveEntityNetworkUpdateController // The spatial bucket transaction runs for EVERY classification, // including both 4a branches: it is the only site that moves an // ordinary moving remote's draw bucket, commits its canonical - // FullCellId (which feeds back as the classifier's own - // CommittedCellId and as the ConstraintDistance cell key), and - // recovers a pending bucket promotion. + // FullCellId (the ConstraintDistance cell key), and recovers a + // pending bucket promotion. + // + // R4 fix round (2026-08-04): the parenthetical used to also claim + // this commit "feeds back as the classifier's own CommittedCellId" — + // false since D1 (this file, above): the remote classifier's + // CommittedCellId is the PRE-merge value threaded on + // AcceptedPhysicsTimestamps, never this post-routing rebucket's + // commit, which runs well after classification has already + // happened for this packet. // // C4 route 4b-2 review fix — this used to end "Neither 4a branch // performs a placement, so unlike route 2 there is no committed @@ -1825,15 +2081,6 @@ internal sealed class LiveEntityNetworkUpdateController return; } - if (remotePlacementRequired) - { - _remoteTeleportController!.BeginPlacement( - update.Guid, - acceptedSpawn.InstanceSequence); - if (!IsCurrentPositionOwner(entity)) - return; - } - // Commit B 2026-04-29 — keep the shadow registry in sync with // server-authoritative position so the player's collision broadphase // tests against the up-to-date target body. Skip the local player @@ -1948,93 +2195,16 @@ internal sealed class LiveEntityNetworkUpdateController return; } - // Retail CPhysicsObj::MoveOrTeleport Branch A (0x00516330): a - // fresh TELEPORT_TS, or the first placement of a cell-less body, - // runs teleport_hook and SetPosition(0x1012) BEFORE the contact - // test. In particular, an airborne UP cannot veto or undo this - // authoritative destination. Do not pre-clear velocity or invent - // grounded flags here: retail SetPosition derives contact from its - // transition, while MoveOrTeleport does not consume arg5/arg6. - if (remotePlacementRequired) - { - double teleportTime = - (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - bool projectionVisible = _liveEntities.TryGetRecord( - update.Guid, - out LiveEntityRecord teleportRecord) - && teleportRecord.IsSpatiallyVisible; - var placement = _remoteTeleportController!.TryApply( - positionRecord, - acceptedPositionAuthorityVersion, - acceptedPositionVelocityAuthorityVersion, - rmState, - entity, - worldPos, - p.LandblockId, - new System.Numerics.Vector3( - p.PositionX, - p.PositionY, - p.PositionZ), - rot, - teleportTime, - projectionVisible, - acceptedSpawn.InstanceSequence, - acceptedSpawn.PositionSequence); - if (placement.Superseded - || !IsCurrentPositionOwner(entity)) - { - return; - } - if (!placement.Applied) - { - entity.SetPosition(rmState.Body.Position); - entity.ParentCellId = rmState.CellId; - entity.Rotation = rmState.Body.Orientation; - if (rmState.CellId != 0) - _liveEntities.RebucketLiveEntity(update.Guid, rmState.CellId); - return; - } - - if (!IsCurrentPositionOwner(entity)) - return; - entity.SetPosition(rmState.Body.Position); - entity.Rotation = rmState.Body.Orientation; - return; - } - - // #167 (Campaign P P5): retail SmartBox::HandleReceivedPosition - // (0x00453fd0) arms the ConstraintManager leash for every remote - // MoveOrTeleport call that returns nonzero (did NOT hard-teleport — - // the remotePlacementRequired branch above already handled and - // returned on the hard-teleport case), anchored to the object's OWN - // current position, generically for player AND NPC remotes (the - // disassembly's "this == player" branch loads identical constants - // either way — see ConstraintDistance). ConstraintManager.ConstrainTo - // captures ConstraintPosOffset = distance(anchor, host.Position) at - // call time; since the anchor here IS host.Position (read live, - // matching every other PositionManager/TargetManager consumer's - // notion of "this object's position"), this always (re)starts the - // leash at zero displacement on a fresh accepted Position, matching - // retail's per-packet re-anchor. - // docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2. - // - // C4 route 4a / D2: retail arms this AFTER the operation, only on - // a nonzero MoveOrTeleport return (@0x00454272, inside the - // `if (MoveOrTeleport(...) != 0)` at @0x00454254) — so this - // pre-operation, unconditional arming is now the LEGACY shape and - // runs only for the classifications the post-operation arm does - // not own. C4 route 4b-2 added the far snap to that set, leaving - // this fallback for the cell-less half, the two rejections, and - // "no classification at all"; 4b-3 deletes it. The gate reads the - // SAME predicate TryArmConstraintAfterOperation does, so exactly - // one of the two sites arms any given classification. - if (!RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( - earlyRemoteRoute) - && rmState.Host is { } remoteConstraintHost) - { - RuntimeRemoteSteadyStatePosition.ArmConstraintAfterOperation( - remoteConstraintHost); - } + // C4 route 4b-3 / D4: retail's single ConstrainTo arming site + // (@0x00454272) is now entirely post-operation + // (TryArmConstraintAfterOperation, called once per arm below, + // after routing). The legacy pre-operation call that used to sit + // here — unconditional arming ahead of the teleport/cell-less + // branch this method now dispatches through + // ApplyRemoteContactRouting — is deleted; retail's own + // teleport_hook (which the placement runs) UnConstrains before + // the post-op site re-arms, matching @0x00514F0C then + // @0x00454272. // Bug B (2026-08-04) — [remote-slide-up]. This is the ONE point // both remote arms pass through, and it deliberately sits AHEAD of @@ -2155,19 +2325,84 @@ internal sealed class LiveEntityNetworkUpdateController { return; } + + // C4 route 4b-3 (D5): the teleport/cell-less classification + // is routed AHEAD of every remaining contact carve-out — + // retail decides @0x00516386 before ever reading arg4 + // (the wire contact bit, @0x0051638E). A teleport-classified + // packet must reach the teleport arm regardless of wire or + // body contact, so this check precedes both the legacy + // wire-airborne fallback below and the LANDING TRANSITION + // block. ApplyRemoteContactRouting runs teleport_hook then + // the canonical placement; the currency re-check afterward + // is the SAME rule the far arm's tail already follows (both + // arms are re-entrant — AP-138). + if (RuntimeRemoteTeleportPosition.OwnsTeleportPlacement( + earlyRemoteRoute)) + { + RemoteContactRouting? teleportRouting = RunRemoteArmTail( + acceptedPositionCanonical, + positionRecord, + rmState, + earlyRemoteRoute, + update.Guid, + worldPos, + rot, + () => IsCurrentPositionOwner(entity)); + if (teleportRouting is null) + return; + + RuntimeRemoteSteadyStatePosition + .TryArmConstraintAfterOperation( + ToConstraintArm(teleportRouting.Value.Arm), + rmState); + + // Invariant 2: the render entity advances from the + // RESOLVED body, and the collision shadow publishes, + // exactly as both grounded arm tails do. + entity.SetPosition(rmState.Body.Position); + entity.ParentCellId = rmState.CellId; + entity.Rotation = rmState.Body.Orientation; + AcDream.App.Physics.LiveEntityShadowPublisher.TryPublishRemote( + _liveEntities, + positionRecord, + entity, + rmState, + acceptedPositionAuthorityVersion, + () => _remotePhysicsUpdater.SyncRemoteShadowToBody( + entity.Id, + rmState, + _origin.CenterX, + _origin.CenterY)); + return; + } + if (!update.IsGrounded) { - // LEGACY airborne no-op, unchanged, for the packets 4a - // does not own — a cell-less remote (route 4b's - // SetPosition), a rejected authority/payload, or no - // classification at all. Those DID take the generic - // render-pose write above, so this still undoes it: the - // body is mid-arc and TickAnimations will write - // entity = body next frame anyway, and setting - // entity = body now prevents a 1-frame - // teleport-to-server-then-yank-back rubber-band. - // 4b deletes this fallback. - entity.SetPosition(rmState.Body.Position); + // C4 route 4b-3 (D2): the retail return-0 shape, applied + // to the two acdream-only leftover classifications (null + // during the login window; RejectedAuthority/RejectedData) + // that remain wire-airborne here — teleport/cell-less + // moved onto the arm above. Shared with the NPC arm's + // identical call (fix round 2026-08-04, R1/A7) so the two + // cannot re-diverge on this shape. This deletes the + // legacy entity-revert quirk + // (entity.SetPosition(rmState.Body.Position)): TickAnimations + // will re-project the mid-arc body next frame regardless, + // and the render entity already holds this packet's wire + // pose from the generic write near the top of OnPosition + // (TryApplyGenericRemoteRenderPose — these classifications + // are not OwnsSteadyState, so that write ran unconditionally + // for this packet); nothing here needs to correct it. The + // cell-adopt this method also performs is redundant here + // (this arm already wrote the identical value + // unconditionally above) but harmless. + ApplyWireAirborneLeftoverBookkeeping( + rmState, + p.LandblockId, + worldPos, + (System.DateTime.UtcNow - System.DateTime.UnixEpoch) + .TotalSeconds); return; } @@ -2227,11 +2462,20 @@ internal sealed class LiveEntityNetworkUpdateController // and SmartBox::HandleReceivedPosition does arm the leash // (@0x00454272). This block returns before the grounded // routing below, so it arms its own — post-move, matching - // the anchor retail reads. Only for a classification 4a - // owns: every other one already armed the legacy - // pre-operation call above. + // the anchor retail reads. The hard-coded NearInterpolate + // arm below is correct for every classification that can + // reach this block (A5 fix round, 2026-08-04) — NOT only + // Interpolate: SetPositionSimple (>=96m), null (login + // window), and RejectedAuthority/RejectedData all reach + // it too whenever the body lacks a contact plane, and + // NearInterpolate is an arming value for every one of + // them per D4's partition table. Only the teleport/ + // cell-less arm is excluded (D5: it dispatches earlier + // and returns before this block can be reached), and + // only NoPositionOperation (wire-airborne) is excluded + // (the AIRBORNE NO-OP return above). RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - earlyRemoteRoute, + RuntimeRemoteAcceptedPositionArm.NearInterpolate, rmState); // C4 route 4a: a landing packet classifies Interpolate, so @@ -2314,58 +2558,53 @@ internal sealed class LiveEntityNetworkUpdateController // on any of these branches. The player arm reaches it only // with Body.InContact == true (the landing block above // returns), so the free-flight carve-out inside is inert here. - bool willBeDrTicked = WillAdvanceRemoteMotion(update.Guid, rmState); - RemoteContactRouting playerRouting = ApplyRemoteContactRouting( - _remotePlacementDrive, - acceptedPositionCanonical, - rmState, - earlyRemoteRoute, - worldPos, - rot, - willBeDrTicked); - - // C4 route 4b-2: the far arm is re-entrant — the canonical - // placement publishes its Place receipt (or, on a non-commit - // outcome, its cancellation receipt) synchronously, and the - // production projection sink can delete or replace this - // incarnation from inside either. Re-validate before ANY - // further write for this packet, exactly as the landing block - // does after MovementManager.HitGround. + // The teleport arm has already dispatched and returned above + // (D5), so `ApplyRemoteContactRouting`'s teleport check can + // never claim a packet here; the hook delegate is passed for + // shape only — there is exactly ONE hook implementation + // (RunRemoteTeleportHook), never a second path. // - // R5 review fix: this now sits BEFORE the leash arming, which - // is the same order the NPC arm has always had — the two arms + // R5 review fix: the currency guard (inside + // RunRemoteArmTail) sits BEFORE the leash arming, which is + // the same order the NPC arm has always had — the two arms // were mirror images of each other and one of them had to be // wrong. Arming is a write (it stamps rmState.Host's - // PositionManager), and this class's own rule is that nothing - // may be written through a superseded owner. The residual - // versus retail's unconditional arm on a nonzero + // PositionManager), and this class's own rule is that + // nothing may be written through a superseded owner. The + // residual versus retail's unconditional arm on a nonzero // MoveOrTeleport return is AP-138. - if (playerRouting.Arm is RemoteContactArm.FarSnapPlacement - && (!IsCurrentPositionOwner(entity) - || !ReferenceEquals( - positionRecord.RemoteMotionRuntime, - rmState))) - { + RemoteContactRouting? playerRoutingResult = RunRemoteArmTail( + acceptedPositionCanonical, + positionRecord, + rmState, + earlyRemoteRoute, + update.Guid, + worldPos, + rot, + () => IsCurrentPositionOwner(entity)); + if (playerRoutingResult is null) return; - } + RemoteContactArm playerArm = playerRoutingResult.Value.Arm; - // D2: ConstrainTo arms strictly AFTER the operation, anchored - // post-move — retail arms it only once MoveOrTeleport returns - // nonzero (@0x00454254/@0x00454272), which the near AND far - // branches both do (@0x005163BE, @0x005163E8). The far branch - // arms on EVERY placement outcome, including a failed one: - // retail discards SetPositionSimple's SetPositionError and - // returns 1 regardless. Every remaining classification already - // armed through the legacy pre-operation call above. + // D2/D4: ConstrainTo arms strictly AFTER the operation, + // anchored post-move — retail arms it only once + // MoveOrTeleport returns nonzero (@0x00454254/@0x00454272), + // which the near, far, AND teleport branches all do + // (@0x005163BE, @0x005163E8, @0x00516438). The far and + // teleport branches arm on EVERY placement outcome, including + // a failed one: retail discards the placement's error and + // returns 1 regardless. This is now the ONLY arming site + // (the legacy pre-operation call is deleted — D4), and + // TryArmConstraintAfterOperation's own partition decides + // whether THIS arm arms. // // Delta review N4: retail's arm is unconditional, acdream's is // not — the currency guard immediately above returns without - // arming when the far arm's synchronous receipt replaced or - // deleted this incarnation. That one-packet gap is the third - // part of AP-138, and this comment must not read as though the - // arm below is reached on every far-snap outcome. + // arming when the far/teleport arm's synchronous receipt + // replaced or deleted this incarnation. That one-packet gap + // is AP-138(3), and applies to both re-entrant arms now. RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - earlyRemoteRoute, + ToConstraintArm(playerArm), rmState); // Track the UP-derived synth velocity for diagnostics @@ -2469,24 +2708,65 @@ internal sealed class LiveEntityNetworkUpdateController return; } - System.Numerics.Vector3? serverVelocity = update.Velocity; - if (serverVelocity is null - && !IsPlayerGuid(update.Guid) - && rmState.LastServerPosTime > 0.0) + // C4 route 4b-3 (D5): hoisted here (was computed further below, + // only for the sticky-gate widening) so the D2 check and the + // synth-velocity gate immediately below can both use it too. + bool isTeleportRoute = RuntimeRemoteTeleportPosition + .OwnsTeleportPlacement(earlyRemoteRoute); + + // C4 route 4b-3 (D2, R1/A7 fix round 2026-08-04): the NPC arm's + // own copy of the retail return-0 shape — previously this arm had + // NO wire-airborne early return for the null/RejectedAuthority/ + // RejectedData leftover set at all, so such a packet fell through + // to ApplyRemoteContactRouting's free-flight carve-out and + // received a body write, an arm, and a render/shadow publish + // retail's return 0 never produces. Shared with the player arm's + // identical call so the two cannot re-diverge on this shape + // again. Never fires for a teleport-classified route (D5 routes + // it ahead of every contact carve-out, including this one). + if (!update.IsGrounded && !isTeleportRoute) { - double elapsed = nowSec - rmState.LastServerPosTime; - if (elapsed > 0.001) - serverVelocity = (worldPos - rmState.LastServerPos) / (float)elapsed; + ApplyWireAirborneLeftoverBookkeeping( + rmState, p.LandblockId, worldPos, nowSec); + return; } - if (serverVelocity is { } authoritativeVelocity) + + // C4 route 4b-3 (R3/A2 fix round 2026-08-04): retail's teleport + // branch writes NO velocity at all (contract invariant 6) — the + // hook's own CancelMoveTo already removes the moveto that would + // otherwise have suppressed a synthesized run cycle, so this + // must positively exclude the teleport route rather than rely on + // an incidental guard. Before this fix a teleport-classified NPC + // packet installed a ~teleport-distance/packet-interval + // synthesized velocity (often 1,000+ m/s) and + // RemoteServerControlledVelocityCycle.Apply (below) planned a + // RunForward cycle from it — a teleported creature visibly + // sprinted in place at the destination until the 0.6s stale- + // velocity watchdog fired. Leaving ServerVelocity/HasServerVelocity + // untouched here is safe: they are consumed only by the gated + // cycle-apply call below (also excluded for this same route), and + // the NEXT non-teleport packet recomputes them fresh from the + // (by-then-updated) LastServerPos, which no longer includes the + // teleport jump. + if (!isTeleportRoute) { - rmState.ServerVelocity = authoritativeVelocity; - rmState.HasServerVelocity = true; - } - else if (!IsPlayerGuid(update.Guid)) - { - rmState.ServerVelocity = System.Numerics.Vector3.Zero; - rmState.HasServerVelocity = false; + System.Numerics.Vector3? serverVelocity = update.Velocity; + if (serverVelocity is null && rmState.LastServerPosTime > 0.0) + { + double elapsed = nowSec - rmState.LastServerPosTime; + if (elapsed > 0.001) + serverVelocity = (worldPos - rmState.LastServerPos) / (float)elapsed; + } + if (serverVelocity is { } authoritativeVelocity) + { + rmState.ServerVelocity = authoritativeVelocity; + rmState.HasServerVelocity = true; + } + else + { + rmState.ServerVelocity = System.Numerics.Vector3.Zero; + rmState.HasServerVelocity = false; + } } // R5-V3 #171 residual (2026-07-04 gate: "flashing/flapping", // stale facing, pushed-into-player): while an entity is STUCK, @@ -2514,16 +2794,22 @@ internal sealed class LiveEntityNetworkUpdateController Console.WriteLine(FormattableString.Invariant( $"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})")); } - var npcRouting = new RemoteContactRouting( - RemoteContactArm.UnroutedCatchUp, Placement: null); - if (!snapSuppressedByStick) + RemoteContactArm npcArm = RemoteContactArm.UnroutedCatchUp; + // C4 route 4b-3 (D5): TS-44's sticky suppression does NOT + // suppress the teleport arm — retail's sticky cannot survive a + // teleport (`UnStick` is the hook's second action, @0x00514EEE), + // so a stuck NPC's teleport packet must still run the hook and + // place. Widen the gate rather than adding a second dispatch + // path. (isTeleportRoute is hoisted above the D2 check now.) + if (!snapSuppressedByStick || isTeleportRoute) { - // C4 routes 4a + 4b-2: the complete near/far/leftover decision - // is the SAME shared entry point the player-remote branch - // above calls; retail's MoveOrTeleport (0x00516330) makes no - // `this == player` distinction, so the two per-kind copies - // became one. TS-44's sticky suppression stays an NPC-only - // CALLER gate (this `if`), which is what its register row + // C4 routes 4a + 4b-2 + 4b-3: the complete near/far/teleport/ + // leftover decision is the SAME shared entry point the + // player-remote branch above calls; retail's MoveOrTeleport + // (0x00516330) makes no `this == player` distinction, so the + // two per-kind copies became one. TS-44's sticky suppression + // stays an NPC-only CALLER gate (this `if`, now widened for + // the teleport arm only), which is what its register row // describes and what the player arm has never had. // // #184 (2026-07-07): an AIRBORNE body keeps its authoritative @@ -2532,45 +2818,41 @@ internal sealed class LiveEntityNetworkUpdateController // Interpolate, so letting route 4a's branch see it before the // airborne test would enqueue a body that must plant, and a // creature knocked off a ledge would glide down over a packet - // interval. Physics digest 2026-07-07 banner. - npcRouting = ApplyRemoteContactRouting( - _remotePlacementDrive, + // interval. Physics digest 2026-07-07 banner. The teleport + // arm is decided even earlier still, inside + // ApplyRemoteContactRouting itself (D5). + // + // Fix round (2026-08-04): the routing-decision-plus-currency + // sequence now runs through the SAME RunRemoteArmTail the + // player arm calls — see its doc for why (A1/A2/R3). + RemoteContactRouting? npcRoutingResult = RunRemoteArmTail( acceptedPositionCanonical, + positionRecord, rmState, earlyRemoteRoute, + update.Guid, worldPos, rot, - WillAdvanceRemoteMotion(update.Guid, rmState)); - - // C4 route 4b-2: the far arm is re-entrant (see - // ApplyRemoteContactRouting's own remarks). Re-validate before - // any further write for this packet — including the leash - // arming below, which is why the player arm now runs this - // check in the SAME position relative to its own arming call - // (R5 review fix). - if (npcRouting.Arm is RemoteContactArm.FarSnapPlacement - && (!IsCurrentPositionOwner(entity) - || !ReferenceEquals( - positionRecord.RemoteMotionRuntime, - rmState))) - { + () => IsCurrentPositionOwner(entity)); + if (npcRoutingResult is null) return; - } + npcArm = npcRoutingResult.Value.Arm; } - // D2: ConstrainTo arms strictly AFTER the operation above, + // D2/D4: ConstrainTo arms strictly AFTER the operation above, // anchored post-move (@0x00454272, inside the - // `if (MoveOrTeleport(...) != 0)` at @0x00454254), and only for a - // classification the post-operation arm owns — every other one - // already armed the legacy pre-operation call above, exactly as - // before. Retail's ConstraintManager leash is independent of the - // acdream-only TS-44 sticky suppression (which only concerns the - // enqueue/snap/placement above), so this deliberately sits OUTSIDE - // the snapSuppressedByStick gate: a stuck NPC's leash still - // re-arms every accepted Position exactly as it did before this - // route split the single call into a per-branch pair. + // `if (MoveOrTeleport(...) != 0)` at @0x00454254). This is now + // the ONLY arming site (the legacy pre-operation call is deleted + // — D4); TryArmConstraintAfterOperation's own partition decides + // whether THIS arm arms. Retail's ConstraintManager leash is + // independent of the acdream-only TS-44 sticky suppression + // (which only concerns the enqueue/snap/placement above), so this + // deliberately sits OUTSIDE the snapSuppressedByStick gate: a + // stuck NPC's leash still re-arms every accepted Position exactly + // as it did before this route split the single call into a + // per-branch pair. RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - earlyRemoteRoute, + ToConstraintArm(npcArm), rmState); // K-fix15 (2026-04-26): DON'T auto-clear airborne on UP. // ACE broadcasts UPs during the arc (peak / mid-fall / land) @@ -2601,7 +2883,7 @@ internal sealed class LiveEntityNetworkUpdateController // C4 route 4b-2: NOT after a far snap — see // TryAdoptWireCellAfterRouting for the rule and why it applies to // this arm and not the player one. - TryAdoptWireCellAfterRouting(rmState, npcRouting.Arm, p.LandblockId); + TryAdoptWireCellAfterRouting(rmState, npcArm, p.LandblockId); // Near UpdatePosition orientation is carried by the same complete // interpolation Frame as translation. Placement, airborne, and @@ -2611,7 +2893,8 @@ internal sealed class LiveEntityNetworkUpdateController rmState.LastServerPos = worldPos; rmState.LastServerPosTime = nowSec; - if (rmState.HasServerVelocity + if (!isTeleportRoute + && rmState.HasServerVelocity && !snapSuppressedByStick && _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity)) { @@ -2622,6 +2905,11 @@ internal sealed class LiveEntityNetworkUpdateController // S5; DEV-2 deleted). Unification of NPCs onto the // CMotionInterp funnel is S6. // + // R3/A2 fix round (2026-08-04): excluded for a teleport- + // classified route above (isTeleportRoute) — retail's + // teleport branch writes no velocity at all, so there is + // nothing here to plan a cycle from. + // // D2 (Commit A 2026-05-03): tag whether the velocity feeding // ApplyServerControlledVelocityCycle is wire-explicit or // synthesized from position deltas (the common case). diff --git a/src/AcDream.App/Physics/RemoteShadowPlacementSynchronizer.cs b/src/AcDream.App/Physics/RemoteShadowPlacementSynchronizer.cs deleted file mode 100644 index 891f4690..00000000 --- a/src/AcDream.App/Physics/RemoteShadowPlacementSynchronizer.cs +++ /dev/null @@ -1,49 +0,0 @@ -using AcDream.App.World; -using AcDream.Core.Physics; -using AcDream.Core.World; - -namespace AcDream.App.Physics; - -/// Synchronizes a resolved remote placement in the current live origin. -internal sealed class RemoteShadowPlacementSynchronizer -{ - private readonly RemotePhysicsUpdater _remotePhysics; - private readonly LiveWorldOriginState _origin; - - public RemoteShadowPlacementSynchronizer( - RemotePhysicsUpdater remotePhysics, - LiveWorldOriginState origin) - { - _remotePhysics = remotePhysics - ?? throw new ArgumentNullException(nameof(remotePhysics)); - _origin = origin ?? throw new ArgumentNullException(nameof(origin)); - } - - public void Sync(WorldEntity entity, PhysicsBody body, uint cellId) => - _remotePhysics.SyncRemoteShadowToBody( - entity.Id, - body, - _origin.CenterX, - _origin.CenterY, - cellId); -} - -/// Bridges remote placement ownership to live presentation state. -internal sealed class RemoteTeleportPlacementPresentation -{ - private readonly LiveEntityPresentationController _presentation; - - public RemoteTeleportPlacementPresentation( - LiveEntityPresentationController presentation) => - _presentation = presentation - ?? throw new ArgumentNullException(nameof(presentation)); - - public void Complete(uint serverGuid, ushort generation, bool deferShadowRestore) => - _presentation.CompleteAuthoritativePlacement( - serverGuid, - generation, - deferShadowRestore); - - public void Begin(uint serverGuid, ushort generation) => - _presentation.BeginAuthoritativePlacement(serverGuid, generation); -} diff --git a/src/AcDream.App/Physics/RemoteTeleportController.cs b/src/AcDream.App/Physics/RemoteTeleportController.cs deleted file mode 100644 index 68fb162c..00000000 --- a/src/AcDream.App/Physics/RemoteTeleportController.cs +++ /dev/null @@ -1,605 +0,0 @@ -using System.Numerics; -using AcDream.App.Rendering; -using AcDream.App.World; -using AcDream.Core.Physics; -using AcDream.Core.World; -using AcDream.Runtime.Entities; - -namespace AcDream.App.Physics; - -/// -/// Owns the SetPosition half of retail remote -/// CPhysicsObj::MoveOrTeleport (0x00516330). It resolves the -/// destination through the placement transition, commits that transition to -/// the existing body, and synchronizes the remote movement/contact state. -/// Logical identity, target-hook actions, rebucketing callbacks, and render -/// presentation remain with their existing owners. -/// -internal sealed class RemoteTeleportController : IDisposable -{ - internal delegate ResolveResult PlacementResolver( - Vector3 position, - uint cellId, - float radius, - float height, - ObjectInfoState moverFlags, - uint movingEntityId); - - private readonly PhysicsEngine _physics; - private readonly LiveEntityRuntime _liveEntities; - private readonly Func _getSetupCylinder; - private readonly Func _cellLocalForSeed; - private readonly Action _syncResolvedShadow; - private readonly Action _completeAuthoritativePlacement; - private readonly Action _beginAuthoritativePlacement; - private readonly PlacementResolver _resolvePlacement; - private readonly Func _getMoverPvpState; - private readonly Dictionary _pending = new(); - - internal RemoteTeleportController( - PhysicsEngine physics, - LiveEntityRuntime liveEntities, - Func getSetupCylinder, - Func cellLocalForSeed, - Action syncResolvedShadow, - Action completeAuthoritativePlacement, - Action beginAuthoritativePlacement, - PlacementResolver? resolvePlacement = null, - // TS-23 (Campaign P Slice P3, 2026-07-30): see - // RemotePhysicsUpdater's identical parameter. - Func? getMoverPvpState = null) - { - _physics = physics ?? throw new ArgumentNullException(nameof(physics)); - _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); - _getSetupCylinder = getSetupCylinder - ?? throw new ArgumentNullException(nameof(getSetupCylinder)); - _cellLocalForSeed = cellLocalForSeed - ?? throw new ArgumentNullException(nameof(cellLocalForSeed)); - _syncResolvedShadow = syncResolvedShadow - ?? throw new ArgumentNullException(nameof(syncResolvedShadow)); - _completeAuthoritativePlacement = completeAuthoritativePlacement - ?? throw new ArgumentNullException(nameof(completeAuthoritativePlacement)); - _beginAuthoritativePlacement = beginAuthoritativePlacement - ?? throw new ArgumentNullException(nameof(beginAuthoritativePlacement)); - _resolvePlacement = resolvePlacement ?? ResolvePlacement; - _getMoverPvpState = getMoverPvpState ?? (static _ => ObjectInfoState.None); - _liveEntities.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; - } - - internal readonly record struct Result( - bool Applied, - bool ContactResolved, - Vector3 Position, - uint CellId, - Quaternion Orientation, - bool Superseded = false); - - private readonly record struct PendingPlacement( - LiveEntityRecord Record, - ulong PositionAuthorityVersion, - ulong VelocityAuthorityVersion, - IRuntimeRemotePlacement Remote, - PhysicsBody Body, - WorldEntity Entity, - Vector3 RequestedWorldPosition, - uint RequestedCellId, - Quaternion RequestedOrientation, - double GameTime, - ushort Generation, - ushort PositionSequence, - bool WasInContact, - bool WasOnWalkable, - RollbackPlacement Rollback); - - private readonly record struct RollbackPlacement( - Vector3 Position, - uint CellId, - Vector3 CellLocalPosition, - Quaternion Orientation, - TransientStateFlags TransientState, - Plane ContactPlane, - bool ContactPlaneValid, - uint ContactPlaneCellId, - bool ContactPlaneIsWater, - double LastUpdateTime, - bool Airborne, - Vector3 LastServerPosition, - double LastServerPositionTime, - Vector3 LastShadowSyncPosition, - Quaternion LastShadowSyncOrientation); - - /// - /// Applies a placement to whichever exact incarnation is current at this - /// call boundary. Packet handlers should use the token-bearing overload; - /// this form serves non-wire orchestration and retains the same callback - /// revalidation once admitted. - /// - internal Result TryApply( - IRuntimeRemotePlacement remote, - WorldEntity entity, - Vector3 requestedWorldPosition, - uint requestedCellId, - Vector3 requestedCellLocalPosition, - Quaternion requestedOrientation, - double gameTime, - bool destinationProjectionVisible, - ushort generation, - ushort positionSequence) - { - if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord record)) - { - throw new InvalidOperationException( - $"Remote placement for 0x{entity.ServerGuid:X8} requires a live incarnation."); - } - - return TryApply( - record, - record.PositionAuthorityVersion, - record.VelocityAuthorityVersion, - remote, - entity, - requestedWorldPosition, - requestedCellId, - requestedCellLocalPosition, - requestedOrientation, - gameTime, - destinationProjectionVisible, - generation, - positionSequence); - } - - internal Result TryApply( - LiveEntityRecord expectedRecord, - ulong expectedPositionAuthorityVersion, - ulong expectedVelocityAuthorityVersion, - IRuntimeRemotePlacement remote, - WorldEntity entity, - Vector3 requestedWorldPosition, - uint requestedCellId, - Vector3 requestedCellLocalPosition, - Quaternion requestedOrientation, - double gameTime, - bool destinationProjectionVisible, - ushort generation, - ushort positionSequence) - { - ArgumentNullException.ThrowIfNull(remote); - ArgumentNullException.ThrowIfNull(entity); - ArgumentNullException.ThrowIfNull(expectedRecord); - if (!_liveEntities.TryGetRecord(entity.ServerGuid, out LiveEntityRecord liveRecord) - || !ReferenceEquals(liveRecord, expectedRecord) - || !_liveEntities.IsCurrentPositionAuthority( - expectedRecord, - expectedPositionAuthorityVersion) - || liveRecord.Generation != generation - || !ReferenceEquals(liveRecord.WorldEntity, entity) - || !ReferenceEquals(liveRecord.RemoteMotionRuntime, remote) - || liveRecord.PhysicsBody is null - || !ReferenceEquals(liveRecord.PhysicsBody, remote.Body)) - { - throw new InvalidOperationException( - $"Remote placement for 0x{entity.ServerGuid:X8} must use its incarnation's canonical physics body."); - } - - bool wasInContact = liveRecord.PhysicsBody.InContact; - bool wasOnWalkable = liveRecord.PhysicsBody.OnWalkable; - RollbackPlacement rollback = CaptureRollback(remote, liveRecord.PhysicsBody); - RuntimeEntityKey key = RequireProjectionKey(liveRecord); - if (_pending.TryGetValue(key, out PendingPlacement prior) - && ReferenceEquals(prior.Record, liveRecord)) - { - wasInContact = prior.WasInContact; - wasOnWalkable = prior.WasOnWalkable; - rollback = prior.Rollback; - } - - PendingPlacement request = new( - liveRecord, - expectedPositionAuthorityVersion, - expectedVelocityAuthorityVersion, - remote, - liveRecord.PhysicsBody, - entity, - requestedWorldPosition, - requestedCellId, - requestedOrientation, - gameTime, - generation, - positionSequence, - wasInContact, - wasOnWalkable, - rollback); - if (!destinationProjectionVisible) - { - if (!ParkPending(request, requestedCellLocalPosition)) - return Superseded(request); - return new Result( - true, - false, - requestedWorldPosition, - requestedCellId, - requestedOrientation); - } - ResolveResult placement = Resolve(request); - if (!IsCurrent(request)) - return Superseded(request); - if (!placement.Ok) - { - _pending.Remove(key); - if (!RollBackDeferredPlacement(request)) - return Superseded(request); - return new Result( - false, - false, - request.Body.Position, - remote.CellId, - request.Body.Orientation); - } - - _pending.Remove(key); - return CommitResolved(request, placement); - } - - internal void Forget(LiveEntityRecord record) - { - ArgumentNullException.ThrowIfNull(record); - if (record.ProjectionKey is { } key - && _pending.TryGetValue(key, out PendingPlacement pending) - && ReferenceEquals(pending.Record, record)) - { - _pending.Remove(key); - } - } - - internal void Clear() - { - _pending.Clear(); - } - - internal bool HasPending(uint serverGuid) => - _liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) - && record.ProjectionKey is { } key - && _pending.ContainsKey(key); - internal int PendingPlacementCount => _pending.Count; - - /// - /// Transfers any older deferred shadow restoration before the accepted - /// destination is rebucketed and can publish a visibility edge. - /// - internal void BeginPlacement(uint serverGuid, ushort generation) => - _beginAuthoritativePlacement(serverGuid, generation); - - public void Dispose() - { - _liveEntities.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged; - Clear(); - } - - private ResolveResult Resolve(PendingPlacement request) - { - var (radius, height) = _getSetupCylinder( - request.Entity.ServerGuid, - request.Entity); - if (radius < 0.05f) - { - radius = 0.48f; - height = 1.835f; - } - - return _resolvePlacement( - request.RequestedWorldPosition, - request.RequestedCellId, - radius, - height, - // TS-23: moverPvpState is a no-op OR (None) for every non-PK - // remote. - (IsPlayerGuid(request.Entity.ServerGuid) - ? ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide - : ObjectInfoState.EdgeSlide) - | _getMoverPvpState(request.Entity.ServerGuid), - request.Entity.Id); - } - - private ResolveResult ResolvePlacement( - Vector3 position, - uint cellId, - float radius, - float height, - ObjectInfoState moverFlags, - uint movingEntityId) => - _physics.ResolvePlacement( - position, - cellId, - radius, - height, - stepUpHeight: 0.4f, - stepDownHeight: 0.4f, - moverFlags: moverFlags, - movingEntityId: movingEntityId); - - private Result CommitResolved(PendingPlacement request, ResolveResult placement) - { - if (!IsCurrent(request)) - return Superseded(request); - if (!RemoteTeleportPlacement.Apply( - request.Remote, - request.Body, - placement, - _cellLocalForSeed(placement.Position, placement.CellId), - request.RequestedOrientation, - request.GameTime, - request.WasInContact, - request.WasOnWalkable, - () => IsCurrent(request), - () => IsVelocityCurrent(request))) - { - return Superseded(request); - } - if (!IsCurrent(request)) - return Superseded(request); - if (request.Remote.CellId != placement.CellId) - request.Remote.CellId = placement.CellId; - if (!IsCurrent(request)) - return Superseded(request); - request.Remote.LastServerPosition = placement.Position; - request.Remote.LastServerPositionTime = request.GameTime; - request.Remote.LastShadowSyncPosition = Vector3.Zero; - request.Remote.LastShadowSyncOrientation = Quaternion.Zero; - request.Entity.SetPosition(request.Body.Position); - request.Entity.ParentCellId = placement.CellId; - request.Entity.Rotation = request.Body.Orientation; - if (!IsCurrent(request)) - return Superseded(request); - if (_liveEntities.TryGetRecord(request.Entity.ServerGuid, out LiveEntityRecord record) - && ReferenceEquals(record, request.Record)) - { - bool deferShadowRestore = - record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden) - || !record.IsSpatiallyVisible; - _completeAuthoritativePlacement( - request.Entity.ServerGuid, - request.Generation, - deferShadowRestore); - if (!IsCurrent(request)) - return Superseded(request); - if (!deferShadowRestore) - { - request.Remote.LastShadowSyncPosition = request.Body.Position; - request.Remote.LastShadowSyncOrientation = request.Body.Orientation; - _syncResolvedShadow(request.Entity, request.Body, placement.CellId); - if (!IsCurrent(request)) - return Superseded(request); - } - } - return new Result( - true, - true, - request.Body.Position, - placement.CellId, - request.Body.Orientation); - } - - private bool ParkPending(PendingPlacement request, Vector3 requestedCellLocalPosition) - { - if (!IsCurrent(request)) - return false; - request.Body.Orientation = request.RequestedOrientation; - request.Body.SnapToCell( - request.RequestedCellId, - request.RequestedWorldPosition, - requestedCellLocalPosition); - request.Body.LastUpdateTime = request.GameTime; - request.Body.ContactPlaneValid = false; - request.Body.ContactPlaneCellId = 0u; - request.Body.ContactPlaneIsWater = false; - PhysicsObjUpdate.ApplySetPositionContact( - request.Body, - inContact: false, - onWalkable: false); - request.Remote.Airborne = true; - if (request.Remote.CellId != request.RequestedCellId) - request.Remote.CellId = request.RequestedCellId; - if (!IsCurrent(request)) - return false; - request.Remote.LastServerPosition = request.RequestedWorldPosition; - request.Remote.LastServerPositionTime = request.GameTime; - request.Remote.LastShadowSyncPosition = Vector3.Zero; - request.Remote.LastShadowSyncOrientation = Quaternion.Zero; - request.Entity.SetPosition(request.RequestedWorldPosition); - request.Entity.ParentCellId = request.RequestedCellId; - request.Entity.Rotation = request.RequestedOrientation; - if (!IsCurrent(request)) - return false; - _pending[RequireProjectionKey(request.Record)] = request; - return true; - } - - private void OnProjectionVisibilityChanged(LiveEntityRecord record, bool visible) - { - if (!visible) - return; - - RuntimeEntityKey key = RequireProjectionKey(record); - if (_pending.TryGetValue(key, out PendingPlacement pending)) - { - if (record.WorldEntity is null - || !ReferenceEquals(record, pending.Record) - || !ReferenceEquals(record.WorldEntity, pending.Entity) - || record.Generation != pending.Generation) - { - _pending.Remove(key); - return; - } - if (record.RemoteMotionRuntime is not IRuntimeRemotePlacement currentRemote - || record.PhysicsBody is null - || !ReferenceEquals(record.PhysicsBody, pending.Body) - || !ReferenceEquals(currentRemote.Body, record.PhysicsBody)) - { - _pending.Remove(key); - if (record.RemoteMotionRuntime is not null - && (record.PhysicsBody is null - || !ReferenceEquals(record.RemoteMotionRuntime.Body, record.PhysicsBody))) - { - _liveEntities.ClearRemoteMotionRuntime(record.ServerGuid); - } - RollBackDeferredPlacement(pending); - return; - } - if (!ReferenceEquals(currentRemote, pending.Remote)) - { - pending = pending with { Remote = currentRemote }; - _pending[key] = pending; - } - if (!_liveEntities.IsCurrentPositionAuthority( - pending.Record, - pending.PositionAuthorityVersion) - || record.Snapshot.PositionSequence != pending.PositionSequence) - { - // A newer accepted UpdatePosition rebucketed the projection - // before its TryApply call could replace this request. Keep - // the original rollback alive for that synchronous call. - return; - } - - _pending.Remove(key); - ResolveResult placement = Resolve(pending); - if (!IsCurrent(pending)) - return; - if (!placement.Ok) - RollBackDeferredPlacement(pending); - else - CommitResolved(pending, placement); - return; - } - - } - - private static RollbackPlacement CaptureRollback( - IRuntimeRemotePlacement remote, - PhysicsBody body) - { - return new RollbackPlacement( - body.Position, - body.CellPosition.ObjCellId, - body.CellPosition.Frame.Origin, - body.Orientation, - body.TransientState, - body.ContactPlane, - body.ContactPlaneValid, - body.ContactPlaneCellId, - body.ContactPlaneIsWater, - body.LastUpdateTime, - remote.Airborne, - remote.LastServerPosition, - remote.LastServerPositionTime, - remote.LastShadowSyncPosition, - remote.LastShadowSyncOrientation); - } - - private bool RollBackDeferredPlacement(PendingPlacement pending) - { - if (!IsCurrentAuthority(pending)) - return false; - RollbackPlacement rollback = pending.Rollback; - PhysicsBody body = pending.Body; - body.Orientation = rollback.Orientation; - body.SnapToCell( - rollback.CellId, - rollback.Position, - rollback.CellLocalPosition); - body.TransientState = rollback.TransientState; - body.ContactPlane = rollback.ContactPlane; - body.ContactPlaneValid = rollback.ContactPlaneValid; - body.ContactPlaneCellId = rollback.ContactPlaneCellId; - body.ContactPlaneIsWater = rollback.ContactPlaneIsWater; - body.LastUpdateTime = rollback.LastUpdateTime; - body.calc_acceleration(); - pending.Remote.Airborne = rollback.Airborne; - pending.Remote.CellId = rollback.CellId; - pending.Remote.LastServerPosition = rollback.LastServerPosition; - pending.Remote.LastServerPositionTime = rollback.LastServerPositionTime; - pending.Remote.LastShadowSyncPosition = rollback.LastShadowSyncPosition; - pending.Remote.LastShadowSyncOrientation = rollback.LastShadowSyncOrientation; - pending.Entity.SetPosition(rollback.Position); - pending.Entity.ParentCellId = rollback.CellId; - pending.Entity.Rotation = rollback.Orientation; - if (!IsCurrentAuthority(pending)) - return false; - - if (rollback.CellId == 0) - _liveEntities.WithdrawLiveEntityProjectionToCellless(pending.Entity.ServerGuid); - else - _liveEntities.RebucketLiveEntity(pending.Entity.ServerGuid, rollback.CellId); - - if (!IsCurrentAuthority(pending) - || !_liveEntities.TryGetRecord( - pending.Entity.ServerGuid, - out LiveEntityRecord restored) - || !ReferenceEquals(restored, pending.Record) - || restored.Generation != pending.Generation) - { - return false; - } - - if (rollback.CellId == 0) - { - _completeAuthoritativePlacement( - pending.Entity.ServerGuid, - pending.Generation, - false); - return IsCurrentAuthority(pending); - } - - bool deferShadowRestore = - restored.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden) - || !restored.IsSpatiallyVisible; - _completeAuthoritativePlacement( - pending.Entity.ServerGuid, - pending.Generation, - deferShadowRestore); - if (!IsCurrentAuthority(pending)) - return false; - if (deferShadowRestore) - return true; - - pending.Remote.LastShadowSyncPosition = Vector3.Zero; - pending.Remote.LastShadowSyncOrientation = Quaternion.Zero; - _syncResolvedShadow(pending.Entity, pending.Body, rollback.CellId); - return IsCurrentAuthority(pending); - } - - private bool IsCurrentAuthority(PendingPlacement request) => - _liveEntities.IsCurrentPositionAuthority( - request.Record, - request.PositionAuthorityVersion) - && request.Record.Generation == request.Generation - && ReferenceEquals(request.Record.WorldEntity, request.Entity) - && ReferenceEquals(request.Record.PhysicsBody, request.Body); - - private bool IsCurrent(PendingPlacement request) => - IsCurrentAuthority(request) - && ReferenceEquals(request.Record.RemoteMotionRuntime, request.Remote); - - private bool IsVelocityCurrent(PendingPlacement request) => - _liveEntities.IsCurrentVelocityAuthority( - request.Record, - request.VelocityAuthorityVersion); - - private static Result Superseded(PendingPlacement request) => new( - Applied: false, - ContactResolved: false, - Position: request.Body.Position, - CellId: request.Remote.CellId, - Orientation: request.Body.Orientation, - Superseded: true); - - private static bool IsPlayerGuid(uint guid) => - (guid & 0xFF000000u) == 0x50000000u; - - private static RuntimeEntityKey RequireProjectionKey( - LiveEntityRecord record) => - record.ProjectionKey - ?? throw new InvalidOperationException( - $"Live entity 0x{record.ServerGuid:X8}/{record.Generation} " + - "has no exact projection key."); -} diff --git a/src/AcDream.App/Physics/RemoteTeleportPlacement.cs b/src/AcDream.App/Physics/RemoteTeleportPlacement.cs deleted file mode 100644 index 3212116a..00000000 --- a/src/AcDream.App/Physics/RemoteTeleportPlacement.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Numerics; -using AcDream.App.World; -using AcDream.Core.Physics; - -namespace AcDream.App.Physics; - -/// -/// Commits the placement half of retail CPhysicsObj::MoveOrTeleport -/// Branch A (0x00516330). The caller runs -/// first, then this exact frame/cell snap, -/// before considering contact-driven interpolation. -/// -internal static class RemoteTeleportPlacement -{ - internal static bool Apply( - IRuntimeRemotePlacement remote, - PhysicsBody body, - ResolveResult placement, - Vector3 cellLocalPosition, - Quaternion orientation, - double gameTime, - bool previousContact, - bool previousOnWalkable, - Func? isCurrent = null, - Func? isVelocityCurrent = null) - { - ArgumentNullException.ThrowIfNull(remote); - ArgumentNullException.ThrowIfNull(body); - if (!placement.Ok - || !IsFinite(placement.Position) - || !IsFinite(cellLocalPosition) - || !PositionFrameValidation.IsValid( - placement.CellId, - cellLocalPosition, - orientation) - || !double.IsFinite(gameTime)) - { - throw new ArgumentOutOfRangeException( - nameof(placement), - "A remote teleport placement requires an accepted finite frame, clock, and nonzero cell."); - } - - body.Orientation = orientation; - body.SnapToCell(placement.CellId, placement.Position, cellLocalPosition); - body.LastUpdateTime = gameTime; - - body.ContactPlaneValid = placement.InContact; - if (placement.InContact) - { - body.ContactPlane = placement.ContactPlane; - body.ContactPlaneCellId = placement.ContactPlaneCellId; - body.ContactPlaneIsWater = placement.ContactPlaneIsWater; - } - else - { - body.ContactPlaneCellId = 0u; - body.ContactPlaneIsWater = false; - } - - if (!PhysicsObjUpdate.CommitSetPositionTransition( - body, - placement.InContact, - placement.OnWalkable, - placement.CollisionNormalValid, - placement.CollisionNormal, - previousContact, - previousOnWalkable, - remote.HitGround, - remote.LeaveGround, - isCurrent, - isVelocityCurrent)) - { - return false; - } - if (isVelocityCurrent?.Invoke() != false) - remote.Airborne = !body.OnWalkable; - return isCurrent?.Invoke() ?? true; - } - - private static bool IsFinite(Vector3 value) => - float.IsFinite(value.X) - && float.IsFinite(value.Y) - && float.IsFinite(value.Z); - -} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e55ef0b6..9b2996bf 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -180,7 +180,6 @@ public sealed class GameWindow : _movementTruthDiagnostics; private readonly LocalPlayerOutboundController _localPlayerOutbound; private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock; - private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; // Step 7 projectile presentation. The controller owns no identity map; // each runtime component is stored on the canonical LiveEntityRecord. private AcDream.App.Physics.ProjectileController? _projectileController; @@ -1001,7 +1000,6 @@ public sealed class GameWindow : || _equippedChildRenderer is not null || _entityEffects is not null || _liveEntityPresentation is not null - || _remoteTeleportController is not null || _projectileController is not null || _liveEntityProjectionWithdrawal is not null || _liveEntityLights is not null @@ -1039,7 +1037,6 @@ public sealed class GameWindow : _equippedChildRenderer = result.EquippedChildren; _entityEffects = result.EntityEffects; _liveEntityPresentation = result.Presentation; - _remoteTeleportController = result.RemoteTeleport; _wbDrawDispatcher = result.DrawDispatcher; _retailSelectionScene = result.SelectionScene; _worldSelectionQuery = result.SelectionQuery; @@ -1638,7 +1635,6 @@ public sealed class GameWindow : _hookRegistrations, _liveEntityLights, _liveEntityPresentation, - _remoteTeleportController, _animationHookFrames, _effectPoses, _audioEngine), diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index 72f66e87..a21a3670 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -92,7 +92,6 @@ internal sealed record LiveShutdownRoots( AnimationHookRegistrationSet? HookRegistrations, LiveEntityLightController? LiveLights, LiveEntityPresentationController? LivePresentation, - RemoteTeleportController? RemoteTeleport, AnimationHookFrameQueue? AnimationHookFrames, EntityEffectPoseRegistry EffectPoses, OpenAlAudioEngine? Audio); @@ -397,7 +396,6 @@ internal static class GameWindowShutdownManifest [ Hard("live lights", () => live.LiveLights?.Dispose()), Hard("live presentation", () => live.LivePresentation?.Dispose()), - Hard("remote teleport", () => live.RemoteTeleport?.Dispose()), Hard("effect network state", () => { live.EntityEffects?.ClearNetworkState(); diff --git a/src/AcDream.App/World/LiveEntityPresentationController.cs b/src/AcDream.App/World/LiveEntityPresentationController.cs index 79653261..5fe0bd32 100644 --- a/src/AcDream.App/World/LiveEntityPresentationController.cs +++ b/src/AcDream.App/World/LiveEntityPresentationController.cs @@ -31,7 +31,6 @@ public sealed class LiveEntityPresentationController : IDisposable private readonly LiveEntityPartArrayEnterWorldPort _partArrayEnterWorld; private readonly HashSet _readyOwners = []; private readonly HashSet _suspendedShadowOwners = []; - private readonly HashSet _activePlacementOwners = []; private readonly HashSet _drainingRecords = new(); private bool _disposed; @@ -102,73 +101,12 @@ public sealed class LiveEntityPresentationController : IDisposable return ApplyPendingTransitions(record); } - /// - /// Retains ownership of a collision shadow that another retail transition - /// suspended while this incarnation's spatial projection is unavailable. - /// Hidden/UnHide and teleport rollback intentionally converge on this one - /// generation-scoped restoration marker. - /// - public bool DeferShadowRestore(uint serverGuid, ushort generation) - => CompleteAuthoritativePlacement(serverGuid, generation, deferShadowRestore: true); - - /// - /// Completes active placement ownership after its final frame/cell is - /// known, either returning a stable deferred restore to presentation or - /// clearing the suspension for an immediate controller-owned restore. - /// - public bool CompleteAuthoritativePlacement( - uint serverGuid, - ushort generation, - bool deferShadowRestore) - { - if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) - || record.Generation != generation - || record.WorldEntity is null) - { - return false; - } - - RuntimeEntityKey key = RequireProjectionKey(record); - _activePlacementOwners.Remove(key); - - if (deferShadowRestore) - _suspendedShadowOwners.Add(key); - else - _suspendedShadowOwners.Remove(key); - return true; - } - - /// - /// Transfers a deferred restore to a newer authoritative placement before - /// that placement rebuckets and can become visible. The placement hands - /// ownership back after it reaches a stable Hidden destination/rollback; - /// this prevents an intervening UnHide from restoring an unresolved pose. - /// - public bool BeginAuthoritativePlacement(uint serverGuid, ushort generation) - { - if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record) - || record.Generation != generation) - { - return false; - } - - RuntimeEntityKey key = RequireProjectionKey(record); - _activePlacementOwners.Add(key); - _suspendedShadowOwners.Remove(key); - return true; - } - internal bool HasDeferredShadowRestore(uint serverGuid) => TryGetCurrentProjectionKey(serverGuid, out RuntimeEntityKey key) && _suspendedShadowOwners.Contains(key); - internal bool HasActivePlacement(uint serverGuid) => - TryGetCurrentProjectionKey(serverGuid, out RuntimeEntityKey key) - && _activePlacementOwners.Contains(key); - internal int ReadyOwnerCount => _readyOwners.Count; internal int DeferredShadowRestoreCount => _suspendedShadowOwners.Count; - internal int ActivePlacementCount => _activePlacementOwners.Count; public void Forget(LiveEntityRecord record) { @@ -177,7 +115,6 @@ public sealed class LiveEntityPresentationController : IDisposable { _readyOwners.Remove(key); _suspendedShadowOwners.Remove(key); - _activePlacementOwners.Remove(key); } _drainingRecords.Remove(record); } @@ -186,7 +123,6 @@ public sealed class LiveEntityPresentationController : IDisposable { _readyOwners.Clear(); _suspendedShadowOwners.Clear(); - _activePlacementOwners.Clear(); _drainingRecords.Clear(); } @@ -233,8 +169,7 @@ public sealed class LiveEntityPresentationController : IDisposable if (!IsCurrent(record, entity)) return false; _shadows.Suspend(entity.Id); - if (!IsPlacementActive(record)) - _suspendedShadowOwners.Add(RequireProjectionKey(record)); + _suspendedShadowOwners.Add(RequireProjectionKey(record)); // Retail CPhysicsObj::set_hidden @ 0x00514C60 calls // CPartArray::HandleEnterWorld after hiding the object // from its cell. Despite the name, this is the motion @@ -261,8 +196,7 @@ public sealed class LiveEntityPresentationController : IDisposable _partArrayEnterWorld.HandleEnterWorld(entity.Id); if (!IsCurrent(record, entity)) return false; - bool restored = !IsPlacementActive(record) - && RestoreShadow(record, entity); + bool restored = RestoreShadow(record, entity); if (!IsCurrent(record, entity)) return false; if (restored) @@ -313,8 +247,12 @@ public sealed class LiveEntityPresentationController : IDisposable // projection edge that suspends its object clock. Keep the retained // registration so a stationary object can be restored immediately on // hydration; it may have no later movement quantum to repair itself. - // Projectiles and active authoritative placements own their matching - // suspend/restore transaction in their dedicated controllers. + // Projectiles own their matching suspend/restore transaction in their + // dedicated controller. C4 route 4b-3 deleted the standalone remote- + // teleport placement controller (and its `_activePlacementOwners` + // suspension gate here) — the canonical Runtime placement owner now + // handles that path, and this class no longer defers to a second + // authority. if (!visible) { SuspendOrdinaryShadowOutsideProjection(record, entity); @@ -322,7 +260,6 @@ public sealed class LiveEntityPresentationController : IDisposable } if ((record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0 - || IsPlacementActive(record) || !TryGetProjectionKey(record, out RuntimeEntityKey key) || !_readyOwners.Contains(key) || !_suspendedShadowOwners.Contains(key)) @@ -343,7 +280,6 @@ public sealed class LiveEntityPresentationController : IDisposable { if (record.IsSpatiallyVisible || record.ProjectileRuntime is not null - || IsPlacementActive(record) || !TryGetProjectionKey(record, out RuntimeEntityKey key) || !_readyOwners.Contains(key) || !_shadows.Suspend(entity.Id)) @@ -362,10 +298,6 @@ public sealed class LiveEntityPresentationController : IDisposable && ReferenceEquals(current, record) && ReferenceEquals(current.WorldEntity, entity); - private bool IsPlacementActive(LiveEntityRecord record) => - TryGetProjectionKey(record, out RuntimeEntityKey key) - && _activePlacementOwners.Contains(key); - private bool TryGetCurrentProjectionKey( uint serverGuid, out RuntimeEntityKey key) diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 40612ecb..4501a642 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -2143,6 +2143,22 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource return false; } + /// + /// R2 fix round (2026-08-04): the App-layer entry point for retail + /// CPhysicsObj::report_collision_end(this, 1) — + /// teleport_hook's sixth action (@0x00514F31 → @0x00514620, + /// force-end-all with bidirectional DoCollisionEnd on both this + /// object and every collision-table partner). Forwards to + /// , the exact + /// retail leave-world/teleport/Hidden force-end entry point — NOT + /// ShadowObjectRegistry.Suspend, which ports a DIFFERENT retail + /// function (remove_shadows_from_cells) that teleport_hook + /// never calls; that mapping predated this fix and was carried without + /// re-derivation. + /// + internal void ForceEndCollisionReporting(RuntimeEntityRecord canonical) => + _physics.CollisionReports.LeaveWorld(canonical); + public bool TryGetRemoteMotionRuntime( uint serverGuid, out IRuntimeRemoteMotion runtime) @@ -2406,29 +2422,16 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource System.Numerics.Vector3? currentLocalVelocity, out PositionTimestampDisposition disposition, out WorldSession.EntitySpawn accepted, - out AcceptedPhysicsTimestamps timestamps) - { - bool projectionRequiresTeleportHook = - _directory.TryGetActive( - update.Guid, - out RuntimeEntityRecord canonical) - && (canonical.FullCellId == 0u - || !_projections.TryGet( - canonical, - out LiveEntityRecord? projection) - || !projection.IsSpatiallyProjected - || !projection.IsSpatiallyVisible); - return _entityObjects.TryApplyPosition( + out AcceptedPhysicsTimestamps timestamps) => + _entityObjects.TryApplyPosition( update, isLocalPlayer, forcePositionRotation, currentLocalVelocity, - projectionRequiresTeleportHook, acknowledgeProjection: null, out disposition, out accepted, out timestamps); - } /// /// C4 route 4a: borrows the canonical Runtime classification for one diff --git a/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs b/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs index e57f15cc..471db410 100644 --- a/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs +++ b/src/AcDream.App/World/LiveEntityRuntimeTeardownController.cs @@ -28,7 +28,6 @@ internal sealed class LiveEntityRuntimeTeardownController private readonly LiveEntityRuntime? _runtime; private readonly LiveEntityPresentationController? _presentation; private readonly EntityEffectController? _effects; - private readonly RemoteTeleportController? _remoteTeleport; private readonly SelectionInteractionController? _interactions; private readonly SelectionState? _selection; private readonly LiveEntityAnimationRuntimeView? _animations; @@ -47,7 +46,6 @@ internal sealed class LiveEntityRuntimeTeardownController LiveEntityRuntime runtime, LiveEntityPresentationController presentation, EntityEffectController effects, - RemoteTeleportController remoteTeleport, SelectionInteractionController? interactions, SelectionState selection, LiveEntityAnimationRuntimeView animations, @@ -63,7 +61,6 @@ internal sealed class LiveEntityRuntimeTeardownController _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _presentation = presentation ?? throw new ArgumentNullException(nameof(presentation)); _effects = effects ?? throw new ArgumentNullException(nameof(effects)); - _remoteTeleport = remoteTeleport ?? throw new ArgumentNullException(nameof(remoteTeleport)); _interactions = interactions; _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _animations = animations ?? throw new ArgumentNullException(nameof(animations)); @@ -110,7 +107,6 @@ internal sealed class LiveEntityRuntimeTeardownController { () => _presentation!.Forget(record), () => _effects!.OnLiveEntityUnregistered(record), - () => _remoteTeleport!.Forget(record), () => { bool replacementExists = diff --git a/src/AcDream.Core/Physics/EntityCollisionFlags.cs b/src/AcDream.Core/Physics/EntityCollisionFlags.cs index f1e9a8f6..f6885808 100644 --- a/src/AcDream.Core/Physics/EntityCollisionFlags.cs +++ b/src/AcDream.Core/Physics/EntityCollisionFlags.cs @@ -140,8 +140,9 @@ public static class EntityCollisionFlagsExt /// ClientObjectTable-backed mover-flags lookup every physics /// call site (local player world-entry, remote DR sweep + teleport, /// ordinary movers) uses — was inlined three times (App - /// GameWindow/LivePresentationComposition/ - /// RemoteTeleportController) before being consolidated here. + /// GameWindow/LivePresentationComposition/the since-deleted + /// RemoteTeleportController, C4 route 4b-3) before being + /// consolidated here. /// An entity with no row, or a row with no wire bitfield yet, resolves /// to — a no-op OR into moverFlags, /// bit-identical to every pre-P3 caller. diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 1b1ae1e1..5831481d 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -218,6 +218,39 @@ public static class PhysicsDiagnostics public static bool ProbeRemoteLandingEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_REMOTE_LANDING") == "1"; + /// + /// C4 route 4b-3 (2026-08-04) live-execution proof (process rule 5): one + /// [remote-teleport] line per routed teleport arm + /// (LiveEntityNetworkUpdateController.ApplyRemoteContactRouting's + /// teleport dispatch), so a connected test can confirm the new arm + /// actually executed rather than inferring it from a clean-looking + /// session (#309's lesson — a session with zero probe lines is a + /// not-run). Initial state from ACDREAM_PROBE_REMOTE_TELEPORT=1. + /// TEMPORARY — strip with the rest of the probe family once the + /// two-client connected teleport gate has landed. + /// + public static bool ProbeRemoteTeleportEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_REMOTE_TELEPORT") == "1"; + + /// + /// Emit one [remote-teleport] line for one routed teleport arm. + /// Self-guards on , so callers + /// need not pre-check. is "teleport-ts" + /// or "cellless" (D1's two classifier predicates); the guid, the + /// hook's own currency result, and the placement status are the fields + /// the connected gate recipe reads back. + /// + public static void LogRemoteTeleport( + uint guid, + string cause, + bool hookRan, + string placementStatus) + { + if (!ProbeRemoteTeleportEnabled) return; + Console.WriteLine(System.FormattableString.Invariant( + $"[remote-teleport] guid=0x{guid:X8} cause={cause} hookRan={hookRan} placement={placementStatus}")); + } + /// /// Emit one [remote-landing] line for a remote landing-detection /// edge. Caller MUST guard with @@ -1253,6 +1286,7 @@ public static class PhysicsDiagnostics ProbeSweptEnabled = false; ProbeStepWalkEnabled = false; ProbeTeleportEnabled = false; + ProbeRemoteTeleportEnabled = false; ProbeRemoteLandingEnabled = false; ProbeRemoteSlideEnabled = false; ProbeRemoteSlideGuids = new System.Collections.Generic.HashSet(); diff --git a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs index 6b729935..f81be848 100644 --- a/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs +++ b/src/AcDream.Runtime/Entities/InboundPhysicsStateController.cs @@ -755,9 +755,8 @@ public sealed class InboundPhysicsStateController /// immediate-apply path did. /// /// A retained - /// continuation only ever exists because - /// -adjacent - /// bookkeeping mutated (see : + /// continuation only ever exists because timestamp-gate bookkeeping + /// mutated (see : /// a Rejected outcome always leaves POSITION_TS and TELEPORT_TS net /// unchanged, so the ONLY dimension that can differ is FORCE_POSITION_TS /// from the local-player force-position fallthrough branch) — apply the @@ -1142,8 +1141,7 @@ public sealed class InboundPhysicsStateController gate.TeleportTimestamp, gate.ForcePositionTimestamp, teleportAdvanced, - TeleportHookRequired: false, - previousTeleport ?? gate.TeleportTimestamp); + PreviousTeleport: previousTeleport ?? gate.TeleportTimestamp); private static WorldSession.EntitySpawn MergeUntimestampedCreate( WorldSession.EntitySpawn retained, @@ -1375,13 +1373,27 @@ public sealed class InboundPhysicsStateController } } +/// +/// — C4 route 4b-3 (D1): the +/// entity's canonical FullCellId measured by +/// +/// BEFORE this packet's merge, for a record that already existed +/// ( when there was no prior canonical record — never a +/// fabricated 0). This is retail's this_1->cell == 0 predicate +/// (CPhysicsObj::MoveOrTeleport @0x00516330, read at entry, before any +/// placement) — "this object has no resolved cell right now" — which is a +/// DIFFERENT question from the post-merge canonical.FullCellId the +/// classifier's CommittedCellId request field reads from every other +/// caller. 0 means the record genuinely had no committed cell (the +/// unwield-to-3D shape); it is never invented. +/// public readonly record struct AcceptedPhysicsTimestamps( ushort Instance, ushort ServerControlledMove, ushort Teleport, ushort ForcePosition, bool TeleportAdvanced = false, - bool TeleportHookRequired = false, + uint? PreMergeCommittedCellId = null, ushort PreviousTeleport = 0); public readonly record struct CreateParentUpdate( diff --git a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs index 6b080fa2..a3b2af5d 100644 --- a/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs +++ b/src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs @@ -622,8 +622,19 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable float? playerDistance) { ArgumentNullException.ThrowIfNull(canonical); - if (_generation is not { } generation) + if (_generation is not { } generation + // C4 route 4b-3 (D1): a remote PositionEvent feeds the + // classifier's cell-less predicate the PRE-merge committed cell + // — the same value TryApplyPosition measured for THIS packet, + // never re-read from the (already merged) canonical record. A + // null here means the merge never observed a prior canonical + // record for this entity, which "Runtime has no opinion" — the + // same policy every other missing-input case in this method + // already uses — covers honestly rather than fabricating 0. + || timestamps.PreMergeCommittedCellId is not { } preMergeCommittedCellId) + { return null; + } if (!RuntimeAcceptedPositionRouteRequests.TryBuild( generation(), canonical, @@ -637,6 +648,7 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable // Retail's UsePositionFromServer is consumed by the local // player branch only; the Remote branch never reads it. usePositionFromServer: false, + preMergeCommittedCellId, out RuntimeAcceptedPositionRouteRequest request)) { return null; @@ -1641,7 +1653,6 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable bool isLocalPlayer, System.Numerics.Quaternion? forcePositionRotation, System.Numerics.Vector3? currentLocalVelocity, - bool projectionRequiresTeleportHook, Action? acknowledgeProjection, out PositionTimestampDisposition disposition, out WorldSession.EntitySpawn accepted, @@ -1713,7 +1724,6 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable update.Guid, out RuntimeEntityRecord beforeCanonical); uint beforeCell = beforeCanonical?.FullCellId ?? 0u; - bool wasCellless = hadCanonical && beforeCell == 0u; bool known = Entities.TryApplyPosition( update, isLocalPlayer, @@ -1743,16 +1753,20 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable bool acceptedPosition = disposition is not PositionTimestampDisposition.Rejected; - if (disposition is PositionTimestampDisposition.Apply) + // C4 route 4b-3 (D1): the classifier's remote cell-less predicate + // needs the PRE-merge committed cell — the value this method just + // measured as `beforeCell`, before `RefreshSnapshot` + // below stamps the accepted wire cell onto the canonical record via + // `RefreshDerivedState` -> `SetFullCell`. Reading `canonical.FullCellId` + // AFTER that merge (as the classifier's default builder overload + // does for every other caller) always sees the wire cell, which is + // why the predicate as fed to a remote PositionEvent was dead before + // this fix. `hadCanonical` is what makes this an honest value rather + // than a fabricated 0 — see the field's own doc. + timestamps = timestamps with { - timestamps = timestamps with - { - TeleportHookRequired = - timestamps.TeleportAdvanced - || wasCellless - || projectionRequiresTeleportHook, - }; - } + PreMergeCommittedCellId = hadCanonical ? beforeCell : null, + }; RuntimePlacementCancellationReceipt cancellation = default; if (acceptedPosition) { diff --git a/src/AcDream.Runtime/Physics/RuntimeAcceptedPositionRouteRequests.cs b/src/AcDream.Runtime/Physics/RuntimeAcceptedPositionRouteRequests.cs index 5536259e..058946f3 100644 --- a/src/AcDream.Runtime/Physics/RuntimeAcceptedPositionRouteRequests.cs +++ b/src/AcDream.Runtime/Physics/RuntimeAcceptedPositionRouteRequests.cs @@ -38,7 +38,46 @@ internal static class RuntimeAcceptedPositionRouteRequests ushort previousTeleportSequence, ushort acceptedTeleportSequence, float playerDistance, - bool usePositionFromServer) + bool usePositionFromServer) => + Build( + generation, + canonical, + key, + update, + entityKind, + source, + disposition, + previousTeleportSequence, + acceptedTeleportSequence, + playerDistance, + usePositionFromServer, + // Route 1's own semantics, unchanged (contract invariant 11): + // the post-merge FullCellId is exactly what this overload has + // always read. + committedCellId: canonical.FullCellId); + + /// + /// C4 route 4b-3 (D1): the overload for a caller that holds the PRE-merge + /// committed cell explicitly, rather than reading the (already merged) + /// canonical record's FullCellId. The only caller today is the + /// remote PositionEvent path + /// (); + /// every other field still comes from exactly + /// as the route-1 overload above reads it. + /// + internal static RuntimeAcceptedPositionRouteRequest Build( + RuntimeGenerationToken generation, + RuntimeEntityRecord canonical, + RuntimeEntityKey key, + in WorldSession.EntityPositionUpdate update, + RuntimePositionEntityKind entityKind, + RuntimeAcceptedPositionSource source, + PositionTimestampDisposition disposition, + ushort previousTeleportSequence, + ushort acceptedTeleportSequence, + float playerDistance, + bool usePositionFromServer, + uint? committedCellId) { ArgumentNullException.ThrowIfNull(canonical); var authority = new RuntimeAuthoritativePositionAuthority( @@ -68,7 +107,7 @@ internal static class RuntimeAcceptedPositionRouteRequests update.Position, update.PlacementId, update.Velocity, - canonical.FullCellId, + committedCellId, update.IsGrounded, playerDistance, usePositionFromServer, @@ -124,4 +163,53 @@ internal static class RuntimeAcceptedPositionRouteRequests usePositionFromServer); return true; } + + /// + /// C4 route 4b-3 (D1): the overload for a caller that holds the PRE-merge + /// committed cell explicitly (see the sibling Build overload's + /// doc) instead of letting this method read the already-merged + /// record. Refuses (returns + /// ) exactly like the overload above, PLUS when + /// itself is — + /// an unknown pre-merge cell is "Runtime has no opinion", never a + /// fabricated 0. + /// + internal static bool TryBuild( + RuntimeGenerationToken generation, + RuntimeEntityRecord canonical, + in WorldSession.EntityPositionUpdate update, + RuntimePositionEntityKind entityKind, + RuntimeAcceptedPositionSource source, + PositionTimestampDisposition disposition, + ushort previousTeleportSequence, + ushort acceptedTeleportSequence, + float? playerDistance, + bool usePositionFromServer, + uint? committedCellId, + out RuntimeAcceptedPositionRouteRequest request) + { + ArgumentNullException.ThrowIfNull(canonical); + if (canonical.Key is not { } key + || playerDistance is not { } distance + || committedCellId is null) + { + request = default; + return false; + } + + request = Build( + generation, + canonical, + key, + update, + entityKind, + source, + disposition, + previousTeleportSequence, + acceptedTeleportSequence, + distance, + usePositionFromServer, + committedCellId); + return true; + } } diff --git a/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs index 60e8ae03..7b214d41 100644 --- a/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs +++ b/src/AcDream.Runtime/Physics/RuntimeAuthoritativePositionRouteClassifier.cs @@ -390,10 +390,15 @@ internal static class RuntimeAuthoritativePositionRouteClassifier reporting); } - // Accepted wire position is intentionally distinct from committed - // residence. A target frame with a nonzero cell does not make a - // cellless canonical body resident; only a later Runtime SetPosition - // or simulation commit may change FullCellId. + // C4 route 4b-3 (D1): CommittedCellId is the caller's own committed + // cell, not derived from the accepted wire frame here — the LOCAL + // player's caller reads its own record's FullCellId (unaffected by + // this packet), and a remote's caller (D1) supplies the PRE-merge + // committed cell measured before the wire frame was merged onto the + // canonical record, never the (already merged) post-merge value. + // This is retail's `this_1->cell == 0` predicate — "this object has + // no resolved cell right now" — read at MoveOrTeleport's entry, + // before any placement. bool cellless = !request.CommittedCellId.HasValue || request.CommittedCellId.Value == 0u; if (request.Authority.TeleportAdvanced || cellless) diff --git a/src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs b/src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs index 2599f44a..1466d245 100644 --- a/src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs +++ b/src/AcDream.Runtime/Physics/RuntimeRemoteFarSnapPosition.cs @@ -21,19 +21,29 @@ internal enum RuntimeRemoteAcceptedPositionArm : byte /// Retail's player_distance >= 96f /// StopInterpolating @0x005163CB + SetPositionSimple - /// @0x005163D9. Route 4b-2 — this slice. + /// @0x005163D9. Route 4b-2. FarSnapPlacement, + /// + /// C4 route 4b-3: retail's teleport/cell-less branch + /// (TELEPORT_TS advanced OR the body's cell is 0) — + /// teleport_hook @0x005163EF then SetPosition + /// @0x00516420, return 1 @0x00516438. Decided AHEAD of the + /// contact test (D5), so this arm never competes with + /// // + /// for the same packet. + /// + TeleportPlacement, + /// /// acdream-only. No retail arm corresponds to it, because retail /// cannot reach the states that produce it: a classification of /// RejectedAuthority/RejectedData (retail validates no wire - /// frame this way), (retail always has a player, so - /// player_distance always exists — acdream returns null for every - /// remote packet during the login window before the local movement - /// controller exists), or the cell-less SetPosition half that route - /// 4b-3 will own. See 's remarks for the stated - /// policy. + /// frame this way), or (retail always has a + /// player, so player_distance always exists — acdream returns + /// null for every remote packet during the login window before the local + /// movement controller exists). See 's remarks + /// for the stated policy. /// UnroutedCatchUp, } @@ -80,106 +90,36 @@ internal static class RuntimeRemoteFarSnapPosition && (route.Value.SetPositionFlags & PhysicsSetPositionFlags.Teleport) != 0; - /// - /// The ONE predicate deciding whether retail's single - /// SmartBox::HandleReceivedPosition ConstrainTo site - /// (@0x00454272, inside if (MoveOrTeleport(...) != 0) @0x00454254) - /// is armed AFTER the position operation for this classification, rather - /// than by the legacy pre-operation App call. - /// - /// - /// Route 4a moved its two dispositions onto the post-operation arm; this - /// slice adds the far snap. Every remaining classification still arms - /// through the legacy pre-operation call site, unchanged. Both the legacy - /// gate and - /// - /// read THIS predicate, so a classification can never be armed twice or - /// not at all. - /// - /// - internal static bool OwnsAfterOperationConstraint( - RuntimeAuthoritativePositionRoute? route) => - RuntimeRemoteSteadyStatePosition.OwnsSteadyState(route) - || OwnsFarSnap(route); - /// /// Selects the arm for one already-classified remote accepted Position. + /// Does NOT decide the teleport arm — C4 route 4b-3 (D5) routes a + /// teleport/cell-less classification (RuntimeRemoteTeleportPosition. + /// OwnsTeleportPlacement) AHEAD of this method entirely, from inside + /// LiveEntityNetworkUpdateController.ApplyRemoteContactRouting, + /// because retail decides that branch before reading the wire contact bit + /// this method's callers have already tested. This method is reached only + /// for a body already known to be in contact and NOT teleport-classified. /// /// /// The - /// policy, stated. Before this slice, every classification route 4a - /// did not own fell into a duplicated App-side near/far block that - /// re-derived retail's player_distance >= 96 m test from - /// _playerController?.Position ?? Vector3.Zero. That test is - /// deleted here rather than preserved, because in every residual case it - /// was already meaningless or wrong: + /// policy, stated. What remains here after 4b-3's teleport/cell-less + /// classification moved out is exactly the two acdream-only divergences + /// retail has no state for at all: /// /// /// — the classifier returned no - /// route precisely BECAUSE there is no local-player position + /// route precisely BECAUSE there is no local-player position yet /// (RuntimeAcceptedPositionRouteRequests.TryBuild refuses to - /// fabricate one; GameRuntime states the rule). The legacy block - /// then measured the distance from a fabricated Vector3.Zero. - /// Review fix — what that actually computed: worldPos is - /// streaming-origin-relative - /// (LiveEntityNetworkUpdateController.cs: local position plus - /// (landblock − _origin.Center) * 192 m), and the streaming origin - /// recentres on the local player's landblock, so the fabricated distance - /// was the remote's distance from the ORIGIN LANDBLOCK'S CORNER — a - /// biased but genuinely correlated proxy for player_distance, - /// its error bounded by the player's own offset inside that landblock - /// (0-192 m per axis). The earlier claim that it had "no relationship to - /// player_distance" was false. It is deleted anyway because a - /// silently-biased proxy for retail's exact 96 m threshold is not a - /// threshold: the bias is up to ~2.8x the threshold itself, so the arm it - /// selects is not the arm retail would select, and there is no honest way - /// to correct for it without the player position the classifier already - /// declined to fabricate. - /// Cell-less SetPosition — the classifier - /// returns at :399-417, BEFORE it ever evaluates the distance. - /// Retail's cell-less body takes the this_1->cell == 0 branch - /// @0x00516386 (SetPosition, flags 0x1012), never the far - /// branch, so applying a far test to it was a divergence in its own - /// right. Review fix — the residual delta, stated: retail's - /// cell-less arm is an UNCONDITIONAL placement sitting ahead of the - /// contact test — teleport_hook @0x005163EF then SetPosition - /// @0x00516420, return 1 @0x00516438 — while acdream now routes - /// that classification here, where ENQUEUES - /// whenever !firstUp && willBeDrTicked && - /// bodyToTarget <= 4 m, at any distance. It is deliberately not - /// changed to place here. Retail's arm is not a pose write: it is - /// teleport_hook @0x00514ED0 — the COMPLETE call list, read from - /// the decomp (delta review N6 added the last of these, which the earlier - /// enumeration dropped): MovementManager::CancelMoveTo @0x00514EDF, - /// PositionManager::UnStick @0x00514EEE, - /// PositionManager::StopInterpolating @0x00514EFD, - /// PositionManager::UnConstrain @0x00514F0C, - /// TargetManager::ClearTarget @0x00514F1B + - /// NotifyVoyeurOfEvent(Teleported_TargetStatus) @0x00514F28, and - /// CPhysicsObj::report_collision_end(this, 1) @0x00514F31 — followed - /// by the canonical flags-0x1012 - /// SetPosition. Writing only the pose here would leave a live - /// moveto, a live stick, a live constraint leash pointing at the old - /// cell, and every collision partner still believing it is in contact, - /// which is strictly worse than the recorded queue. Porting the - /// whole arm is route 4b-3's entire scope; claiming half of it here is - /// route 4a's "'not Interpolate' is not 'far'" mistake one level up. - /// Recorded as AP-137. + /// fabricate one; GameRuntime states the rule), or because the + /// merge observed no PRIOR canonical record for this entity (D1 — an + /// honest "no opinion" rather than a fabricated pre-merge + /// cell). /// RejectedAuthority/RejectedData — the /// packet's authority or payload was refused; retail, which validates - /// neither, has no state here at all. Review fix — stated plainly: - /// this arm therefore APPLIES a payload the classifier just marked - /// invalid. ClassifyAcceptedPosition emits RejectedData from - /// two sites a remote can reach — an accepted wire position that fails - /// ValidPosition (non-finite origin/orientation or a frame - /// PositionFrameValidation refuses), and a non-finite or negative - /// derived player_distance — and both then take this arm. That is - /// not a regression — the deleted legacy block fed the same payload to the - /// same snap/enqueue pair — but this slice's stated purpose was an - /// EXPLICIT handler, so it is named rather than left implied. Retiring it - /// means giving the two rejections their own no-operation arm, which is a - /// behaviour change route 4b-3 must decide with the cell-less half, not a - /// tidy-up. Recorded as AP-137. + /// neither, has no state here at all. This arm therefore APPLIES a + /// payload the classifier just marked invalid — not a regression (the + /// deleted legacy block fed the same payload to the same snap/enqueue + /// pair), but named rather than left implied. /// /// /// What remains is exactly AP-87 — the shared @@ -188,10 +128,8 @@ internal static class RuntimeRemoteFarSnapPosition /// firstUp || !willBeDrTicked || bodyToTarget > 4 m still PLACES /// an unplaced or badly-lagging body. So a remote keeps tracking the /// server through the login window and through a rejected packet; it is - /// never silently frozen, which is what deleting the legacy block without - /// a replacement would have done. The single 4 m constant now lives only - /// in RuntimeRemoteSteadyStatePosition; the App's two duplicated - /// copies are gone. + /// never silently frozen. The single 4 m constant lives only in + /// RuntimeRemoteSteadyStatePosition. /// /// internal static RuntimeRemoteAcceptedPositionArm ResolveArm( diff --git a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs index 1bf6e6fe..b294db3a 100644 --- a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs +++ b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs @@ -516,10 +516,12 @@ internal sealed class RuntimeRemotePhysicsUpdater // callbacks between the contact prefix and // handle_all_collisions are HitGround/LeaveGround and the // ownership re-check. The packet-driven placement paths are - // the ones that need it: `RemoteTeleportPlacement.Apply` is - // the only caller that passes the delegate, and - // `RuntimeSetPositionState`'s canonical commit makes the same - // check inline around its own `HandleAllCollisions`. + // the ones that need it: `RuntimeSetPositionState`'s + // canonical commit (which now backs every remote packet- + // driven placement, teleport included — C4 route 4b-3 + // deleted the `RemoteTeleportPlacement.Apply` caller this + // note used to name) makes the same check inline around its + // own `HandleAllCollisions`. // // It is spelled out through its own public sub-steps // (CommitSetPositionContactPrefix / the ground edge / @@ -663,11 +665,14 @@ internal sealed class RuntimeRemotePhysicsUpdater // Bug B (2026-08-04): Airborne is DERIVED from the committed // ON_WALKABLE transient, never latched by a landing test. // This is the project's ONE definition of the flag — every - // writer spells `!Body.OnWalkable`, and there are FIVE of + // writer spells `!Body.OnWalkable`, and there are FOUR of // them: `SettleSpawnedRemoteContact` (the spawn-settle - // tail) and `RemoteTeleportPlacement.Apply` in App, - // `RuntimeSetPositionState`'s canonical placement commit, - // and this file's two (here and the `TickHidden` resolve). + // tail), `RuntimeSetPositionState`'s canonical placement + // commit (C4 route 4b-3 retired the fifth writer this + // note used to name, `RemoteTeleportPlacement.Apply` in + // App — the teleport path's `Airborne` derivation is now + // this same canonical commit's), and this file's two + // (here and the `TickHidden` resolve). // `PlayerMovementController.IsAirborne` computes the same // predicate for the local player. It stays unchanged here; // only the fact it is derived FROM has moved, from a diff --git a/src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs b/src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs index 28fd1030..632299c4 100644 --- a/src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs +++ b/src/AcDream.Runtime/Physics/RuntimeRemoteSteadyStatePosition.cs @@ -196,7 +196,7 @@ internal static class RuntimeRemoteSteadyStatePosition } /// - /// D2: retail arms CPhysicsObj::ConstrainTo strictly AFTER + /// D2/D4: retail arms CPhysicsObj::ConstrainTo strictly AFTER /// MoveOrTeleport returns nonzero, anchored to the object's own /// CURRENT (i.e. post-move) position — SmartBox::HandleReceivedPosition /// 0x00453FD0 reads &arg2->m_position at 0x00454272, inside @@ -204,38 +204,55 @@ internal static class RuntimeRemoteSteadyStatePosition /// does NOT run on the airborne no-op. /// /// - /// Route-gated by - /// - /// — route 4a's two dispositions plus (C4 route 4b-2) the far snap. Every - /// other classification still arms the leash through the legacy - /// pre-operation call site, unchanged, until 4b-3 moves them too. The - /// legacy site reads the SAME predicate, so no classification can be - /// armed twice or left unarmed. - /// - /// - /// - /// This deliberately does not consult the outcome of the operation it - /// follows. Retail's far branch returns 1 @0x005163E8 unconditionally — - /// MoveOrTeleport discards SetPositionSimple's - /// enum SetPositionError return entirely — so - /// HandleReceivedPosition arms the leash even when the placement - /// FAILED. "Arm only on commit" is the natural misreading and is the same - /// shape as the already-recorded unarmed-leash bug. + /// C4 route 4b-3 (D4): this is now the ONLY arming site — the legacy + /// pre-operation call is deleted, matching retail's single + /// @0x00454272. is the routing outcome (which arm + /// actually claimed the packet), NOT the raw classification, because + /// that is the only input that correctly distinguishes a GROUNDED + /// (arms — + /// retail has no state here, but the analogue of "MoveOrTeleport returned + /// nonzero" is true) from the wire-airborne leftover shape (D2's + /// return-0 replacement, which never reaches this call at all — see the + /// caller). The complete partition: /// + /// + /// + /// — arms on EVERY placement outcome (retail discards + /// SetPosition's error and returns 1 unconditionally + /// @0x00516438). + /// + /// — arms unconditionally, same reason + /// (@0x005163E8). + /// + /// — arms; retail's InterpolateTo branch returns 1 + /// (@0x005163BE). + /// + /// — arms; only reachable here when the body is already known to be in + /// contact (the caller's free-flight carve-out already + /// returned). + /// + /// — never arms; retail's arg4 == 0 branch returns 0 + /// (@0x0051636D). Both production callers already early-return on this + /// classification before reaching any arming call, so this case is + /// defensive. + /// /// internal static bool TryArmConstraintAfterOperation( - RuntimeAuthoritativePositionRoute? route, + RuntimeRemoteAcceptedPositionArm arm, RemoteMotion remote) { ArgumentNullException.ThrowIfNull(remote); - if (route is not { } selected - || !RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( - selected) - || !selected.ConstrainAfterRouting - || remote.Host is not { } host) + bool arms = arm switch { + RuntimeRemoteAcceptedPositionArm.TeleportPlacement => true, + RuntimeRemoteAcceptedPositionArm.FarSnapPlacement => true, + RuntimeRemoteAcceptedPositionArm.NearInterpolate => true, + RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp => true, + RuntimeRemoteAcceptedPositionArm.AirborneNoOperation => false, + _ => false, + }; + if (!arms || remote.Host is not { } host) return false; - } ArmConstraintAfterOperation(host); return true; diff --git a/src/AcDream.Runtime/Physics/RuntimeRemoteTeleportPosition.cs b/src/AcDream.Runtime/Physics/RuntimeRemoteTeleportPosition.cs new file mode 100644 index 00000000..4bdda3e3 --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimeRemoteTeleportPosition.cs @@ -0,0 +1,47 @@ +using AcDream.Core.Physics; + +namespace AcDream.Runtime.Physics; + +/// +/// C4 route 4b-3 (2026-08-04): the remote teleport/cell-less arm selection — +/// retail's CPhysicsObj::MoveOrTeleport (0x00516330) branch decided at +/// entry, BEFORE the contact test: this_1->cell == 0 || newer_event(TELEPORT_TS) +/// @0x00516375-@0x00516386. Sibling to +/// (the >=96 m far snap) and +/// (airborne no-op / near interpolate); together the three cover every arm a +/// remote's accepted Position can take. +/// +internal static class RuntimeRemoteTeleportPosition +{ + /// + /// True when route 4b-3 owns this classification: retail's teleport/ + /// cell-less branch, @0x00516386-@0x00516438. + /// + /// + /// The disposition alone is NOT sufficient — the classifier emits + /// SetPosition for a remote top-level Create too + /// (RuntimeAuthoritativePositionRouteClassifier.ClassifyCreate, + /// flags Placement|Slide). The Teleport flag bit is the + /// exact discriminator — an accepted remote Position's teleport/cell-less + /// branch carries retail's Teleport|Slide|SendPositionEvent = + /// 0x1012, exactly the flags a Create never sets — the same + /// discriminator + /// uses for its own disposition. This predicate is therefore a strict + /// narrowing of + /// + /// to its teleport half, exactly as OwnsFarSnap narrows to its far + /// half — the two partition OwnsPlacement's remote scope + /// completely and disjointly (a route can never carry both + /// SetPosition and SetPositionSimple at once). + /// + /// + internal static bool OwnsTeleportPlacement( + RuntimeAuthoritativePositionRoute? route) => + route is + { + Disposition: RuntimeAuthoritativePositionDisposition.SetPosition, + OperationKind: RuntimeSetPositionOperationKind.RemoteAuthoritative, + } + && (route.Value.SetPositionFlags & PhysicsSetPositionFlags.Teleport) + != 0; +} diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 461007f5..e56cb112 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -4536,9 +4536,12 @@ internal sealed class RuntimeSetPositionState : IDisposable /// read from that same record field. It is NOT invariant for a RETAINED /// operation: both drives re-submit from their own cadence pump with no /// fresh merge in between (SubmitAndResolve re-reads the record as - /// it then stands), and RemoteTeleportController's rollback is a - /// shipped writer that rebuckets FullCellId back to the - /// PRE-teleport landblock. RuntimeRemotePlacementDriveController's + /// it then stands). The surviving non-Position rebucket writers (C4 + /// route 4b-3 deleted the third, RemoteTeleportController's + /// rollback) are the projection materializer + /// (DatLiveEntityProjectionMaterializer) and the equipped-child + /// renderer (EquippedChildRenderController.TickChild). + /// RuntimeRemotePlacementDriveController's /// CanAttemptDestination doc states this correctly; treat the arm /// as live, not as dead code. /// diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index 14e15f7b..db0ed0a0 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -204,7 +204,6 @@ public sealed class RuntimeLiveEntitySessionController isLocal, forcePositionRotation: localController?.BodyOrientation, currentLocalVelocity: localController?.BodyVelocity, - projectionRequiresTeleportHook: false, acknowledgeProjection: null, out PositionTimestampDisposition disposition, out _, diff --git a/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs b/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs index b91c7369..10454702 100644 --- a/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs +++ b/src/AcDream.Runtime/Session/RuntimeRemotePlacementDriveController.cs @@ -783,6 +783,65 @@ internal sealed class RuntimeRemotePlacementDriveController return status; } + /// + /// C4 route 4b-3: retail's teleport/cell-less branch, end to end, for one + /// remote whose accepted Position already classified to + /// . + /// + /// + /// 00516386 if (newer_event(TELEPORT_TS) || this_1->cell == 0) + /// 005163ef CPhysicsObj::teleport_hook(this_1, edx_2); + /// 00516414 SetPositionStruct::SetFlags(&var_64, 0x1012); + /// 00516420 CPhysicsObj::SetPosition(this_1, &var_64); + /// 00516438 return 1; + /// + /// + /// + /// The teleport hook is the CALLER's responsibility (D3) — it must run + /// BEFORE this method, regardless of what the placement then yields, + /// exactly like retail's ordering. This method does not clear the + /// interpolation queue itself: unlike the far arm, the classifier's + /// teleport branch carries StopInterpolating: false on purpose — + /// retail's clear for THIS branch lives inside teleport_hook's + /// PositionManager::StopInterpolating @0x00514EFD, not in + /// MoveOrTeleport itself. + /// + /// + /// + /// The store_position fallback (invariant 1) is identical to the + /// far arm's: every outcome for which the canonical placement never + /// reached the engine + /// () + /// still advances the body to the accepted destination pose. Retail + /// discards SetPosition's error and returns 1 unconditionally + /// @0x00516438 — the placement's outcome never changes whether the + /// packet "succeeded". + /// + /// + internal RuntimeRemotePlacementExecutionStatus ApplyAcceptedRemoteTeleport( + RuntimeEntityRecord record, + RemoteMotion remote, + in RuntimeAuthoritativePositionRoute route) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(remote); + if (!RuntimeRemoteTeleportPosition.OwnsTeleportPlacement(route)) + { + throw new ArgumentException( + "Only a remote teleport/cell-less classification (SetPosition, " + + "RemoteAuthoritative, Teleport-flagged) may be applied " + + "through the teleport arm; the caller must select the arm " + + "with RuntimeRemoteTeleportPosition.OwnsTeleportPlacement.", + nameof(route)); + } + + RuntimeRemotePlacementExecutionStatus status = + TryExecuteAcceptedRemotePosition(record, route); + if (status.StoresAcceptedDestination()) + StoreAcceptedDestinationPose(record); + return status; + } + /// /// Retail CPhysicsObj::store_position @0x00515CE2, reached from /// SetPositionInternal's no-resolvable-cell branch @0x00515C1D. @@ -1248,10 +1307,13 @@ internal sealed class RuntimeRemotePlacementDriveController /// /// /// re-reads this predicate and is subject to the - /// same two gaps, plus a third: a non-Position rebucket - /// (RemoteTeleportController, the projection materializer, the - /// equipped-child renderer) can move record.FullCellId to a THIRD - /// landblock between the retained submit and the retry. All three are + /// same two gaps, plus a third: a non-Position rebucket (the projection + /// materializer DatLiveEntityProjectionMaterializer, the + /// equipped-child renderer EquippedChildRenderController.TickChild + /// — C4 route 4b-3 deleted the third shipped writer, + /// RemoteTeleportController's rollback) can move + /// record.FullCellId to a THIRD landblock between the retained + /// submit and the retry. Both remaining writers are /// harmless for the same reason (delta review N3). That reason is the /// paragraph below — NOT, as the round-2 text claimed, that re-reading /// record.CurrentCellId here would "re-derive a private Core diff --git a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs index 51541dcd..b9235a49 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionResetPlanTests.cs @@ -225,7 +225,6 @@ public sealed class LiveSessionResetPlanTests }, SessionIdentityPresentation = _ => calls.Add("session identity presentation"), - RemoteTeleport = Stage("remote teleport"), NetworkEffects = Stage("network effects"), AnimationHookFrames = Stage("animation hook frames"), LivePresentation = Stage("live presentation"), @@ -306,7 +305,6 @@ public sealed class LiveSessionResetPlanTests "live liveness", "runtime generation", "session identity presentation", - "remote teleport", "network effects", "animation hook frames", "live presentation", diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteFarSnapIntegrationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteFarSnapIntegrationTests.cs index 23f41196..97072138 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteFarSnapIntegrationTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteFarSnapIntegrationTests.cs @@ -56,7 +56,8 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests Classify(hasContact: true, playerDistance: 200f), DecoyWirePose, Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => true); Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm.FarSnapPlacement, @@ -105,7 +106,8 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests Classify(hasContact: true, playerDistance: 200f), DecoyWirePose, Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => true); Assert.False(remote.Interp.IsActive); fixture.DrainPlacementFifo(); @@ -146,7 +148,8 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests Classify(hasContact: true, playerDistance: 200f), DecoyWirePose, Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => true); Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm.FarSnapPlacement, @@ -214,7 +217,8 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests Classify(hasContact: true, playerDistance: 200f), DecoyWirePose, Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => true); Assert.Equal( RuntimeRemotePlacementExecutionStatus.Refused, @@ -254,7 +258,8 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests route: null, target, Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => true); Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm.UnroutedCatchUp, @@ -294,7 +299,8 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests route: null, target, Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => true); Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm.UnroutedCatchUp, @@ -327,7 +333,8 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests rejected, target, Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => true); Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm.UnroutedCatchUp, @@ -337,13 +344,16 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests } /// - /// Retail's cell-less body takes this_1->cell == 0 @0x00516386 - /// (SetPosition), never the far branch — and route 4b-3, not this - /// slice, owns it. Claiming it here would be exactly route 4a's - /// "'not Interpolate' is not 'far'" finding one level up. + /// C4 route 4b-3 (D1/D5): retail's cell-less body takes + /// this_1->cell == 0 @0x00516386 — the SAME teleport branch as a + /// fresh TELEPORT_TS — never the far/near/leftover arms. The hook runs + /// (before the placement, per D3) and the body ends at the canonical + /// RESOLVED destination, not at the caller's separately-supplied + /// wire pose (the decoy discriminates the same + /// way the far-snap test above does). /// [Fact] - public void CellLessRemote_TakesTheUnroutedCatchUp_AndNeverThePlacementOwner() + public void CellLessRemote_TakesTheTeleportArm_RunsTheHookAndPlacesCanonically() { using var fixture = new RemotePlacementDriveFixture(); fixture.PublishDestinationCollision(); @@ -360,6 +370,7 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests RuntimeAuthoritativePositionDisposition.SetPosition, cellLess.Disposition); + int hookCalls = 0; LiveEntityNetworkUpdateController.RemoteContactRouting routing = LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( fixture.Drive, @@ -368,12 +379,27 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests cellLess, target, Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => + { + hookCalls++; + return true; + }); + Assert.Equal(1, hookCalls); Assert.Equal( - LiveEntityNetworkUpdateController.RemoteContactArm.UnroutedCatchUp, + LiveEntityNetworkUpdateController.RemoteContactArm.TeleportPlacement, routing.Arm); - Assert.Equal(target, body.Position); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Committed, + routing.Placement); + Assert.Equal( + Destination + RemotePlacementDriveFixture.DestinationWorldOffset, + body.Position); + Assert.NotEqual(target, body.Position); + Assert.Equal(RemotePlacementDriveFixture.DestinationCell, record.FullCellId); + + fixture.DrainPlacementFifo(); Assert.Equal(0, fixture.LiveOperationCount); Assert.Equal(0, fixture.RemotePlacementLedger); } @@ -409,7 +435,8 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests Classify(hasContact: true, playerDistance: 200f), target, Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => true); Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm.AirborneSnap, @@ -418,6 +445,77 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests Assert.Equal(0, fixture.LiveOperationCount); } + /// + /// C4 route 4b-3, test-plan item 6 / contract D5: the teleport/cell-less + /// classification is decided BEFORE ApplyRemoteContactRouting ever + /// reads remote.Body.InContact — retail's MoveOrTeleport + /// tests this_1->cell == 0 || newer_event(TELEPORT_TS) + /// @0x00516375-@0x00516386 strictly before the wire-contact branch at + /// @0x0051638E. A mid-arc body's teleport packet must therefore still + /// place canonically — the OPPOSITE outcome of + /// above, which proves the + /// far arm defers to airborne precedence while this proves the teleport + /// arm does not. Fails against a broken ordering that lets the airborne + /// carve-out claim the packet first: would then + /// sit at the raw target wire pose the airborne branch writes, + /// never resolved through the canonical destination. + /// + [Fact] + public void AirborneBody_TeleportOutranksAirborneSnap() + { + using var fixture = new RemotePlacementDriveFixture(); + fixture.PublishDestinationCollision(); + fixture.AllowDestination(); + (RuntimeEntityRecord record, RemoteMotion remote, PhysicsBody body) = + fixture.AddRemote(0x7000400Cu, Destination); + // Same mid-arc state as AirborneBody_OutranksTheFarSnap: no wire + // contact, free flight in both the old and new terms of the gate. + remote.Airborne = true; + remote.Body.TransientState = TransientStateFlags.Active; + var target = new Vector3(60f, 10f, 7f); + + RuntimeAuthoritativePositionRoute cellLess = Classify( + hasContact: false, + playerDistance: 200f, + committedCellId: 0u); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPosition, + cellLess.Disposition); + + int hookCalls = 0; + LiveEntityNetworkUpdateController.RemoteContactRouting routing = + LiveEntityNetworkUpdateController.ApplyRemoteContactRouting( + fixture.Drive, + record, + remote, + cellLess, + target, + Quaternion.Identity, + willBeDrTicked: true, + runTeleportHook: () => + { + hookCalls++; + return true; + }); + + Assert.Equal(1, hookCalls); + Assert.Equal( + LiveEntityNetworkUpdateController.RemoteContactArm.TeleportPlacement, + routing.Arm); + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Committed, + routing.Placement); + Assert.Equal( + Destination + RemotePlacementDriveFixture.DestinationWorldOffset, + body.Position); + Assert.NotEqual(target, body.Position); + Assert.Equal(RemotePlacementDriveFixture.DestinationCell, record.FullCellId); + + fixture.DrainPlacementFifo(); + Assert.Equal(0, fixture.LiveOperationCount); + Assert.Equal(0, fixture.RemotePlacementLedger); + } + /// /// Per-entity independence across the two arms in the same tick: one /// remote's committed placement must not touch another's body or leak @@ -444,7 +542,8 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests Classify(hasContact: true, playerDistance: 200f), DecoyWirePose, Quaternion.Identity, - willBeDrTicked: true).Arm); + willBeDrTicked: true, + runTeleportHook: () => true).Arm); Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm .SteadyStateInterpolate, @@ -455,7 +554,8 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests Classify(hasContact: true, playerDistance: 10f), nearBefore + new Vector3(0.5f, 0f, 0f), Quaternion.Identity, - willBeDrTicked: true).Arm); + willBeDrTicked: true, + runTeleportHook: () => true).Arm); Assert.Equal( Destination + RemotePlacementDriveFixture.DestinationWorldOffset, @@ -497,7 +597,8 @@ public sealed class LiveEntityNetworkRemoteFarSnapIntegrationTests airborne, new Vector3(60f, 10f, 7f), Quaternion.Identity, - willBeDrTicked: true)); + willBeDrTicked: true, + runTeleportHook: () => true)); Assert.Equal(before, body.Position); Assert.False(remote.Interp.IsActive); diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteSteadyStateIntegrationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteSteadyStateIntegrationTests.cs index 8bcce1f9..33b60185 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteSteadyStateIntegrationTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteSteadyStateIntegrationTests.cs @@ -177,7 +177,8 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests route, landing, Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => true); Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm.AirborneSnap, @@ -207,7 +208,8 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests Classify(hasContact: true, playerDistance: 10f), target, Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => true); Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm @@ -263,7 +265,8 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests Classify(hasContact: true, playerDistance: 10f), before + new Vector3(0.5f, 0f, 0f), Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => true); Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm @@ -302,7 +305,8 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests Classify(hasContact: true, playerDistance: 10f), landing, Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => true); Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm.AirborneSnap, @@ -338,7 +342,8 @@ public sealed class LiveEntityNetworkRemoteSteadyStateIntegrationTests Classify(hasContact: true, playerDistance: 10f), before + new Vector3(0.5f, 0f, 0f), Quaternion.Identity, - willBeDrTicked: true); + willBeDrTicked: true, + runTeleportHook: () => true); Assert.Equal( LiveEntityNetworkUpdateController.RemoteContactArm diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs new file mode 100644 index 00000000..74f03454 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/LiveEntityNetworkRemoteTeleportPresentationTests.cs @@ -0,0 +1,1133 @@ +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Net; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.Update; +using AcDream.App.World; +using AcDream.Content; +using AcDream.Content.Pak; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; +using AcDream.Runtime; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Lib.IO; + +namespace AcDream.App.Tests.Physics; + +/// +/// C4 route 4b-3, test-plan item 9 (the #312 layer, strong form): drives the +/// COMPLETE production entry point — a real +/// call, not the +/// extracted +/// seam the far-snap/steady-state integration tests use — for a remote +/// teleport, and asserts the render layer directly: the +/// pose, ParentCellId, spatial visibility, and collision-shadow +/// publication. #312 shipped because a presentation-restore fix's tests +/// asserted only InWorld/clock/residency, never this layer — these +/// tests exist so the same class of regression cannot ship silently for the +/// teleport arm. +/// +/// +/// Every dependency is a REAL production class except the ~15 interface +/// seams LiveEntityNetworkUpdateController takes for local-player-only +/// or DAT-backed concerns that a remote-teleport call never reaches (verified +/// by reading OnPosition: _dats/_animLoader are read only +/// from OnMotion; _localPlayerTeleport/_playerHostSource/ +/// _acceptedPositionDrive are gated on update.Guid == +/// _playerServerGuid, which the remote guid here never satisfies). The +/// canonical is shared between +/// and , +/// exactly like production's SessionPlayerComposition wires them. +/// +/// +/// +/// deliberately does NOT satisfy +/// IsPlayerGuid ((guid & 0xFF000000u) == 0x50000000u), so +/// OnPosition takes the "GROUNDED ROUTING" creature/NPC branch and +/// its own render+shadow tail (the block after the +/// #184: sync the NPC shadow... comment), not the sibling copy inside +/// the IsPlayerGuid branch above it — the two are separate inline +/// copies of the same invariant, not a shared helper. Verified live: a +/// sabotage of the wrong (player-guid) copy left both tests green, which is +/// what caught the branch mismatch during authoring; sabotaging the actual +/// NPC-branch copy fails both tests as expected. +/// +/// +public sealed class LiveEntityNetworkRemoteTeleportPresentationTests +{ + private const uint SourceLandblock = 0xB1000000u; + private const uint SourceCell = SourceLandblock | 0x0001u; + private const uint DestinationLandblock = 0xB2000000u; + private const uint DestinationCell = DestinationLandblock | 0x0001u; + private static readonly Vector3 DestinationWorldOffset = new(192f, 0f, 0f); + private const uint RemoteGuid = 0x70006001u; + /// + /// In IsPlayerGuid's 0x50xxxxxx range but distinct from + /// the fixture's own NoopIdentitySource.ServerGuid + /// (0x50000099u) — an OTHER player's remote character, exactly + /// the guid shape that takes OnPosition's IsPlayerGuid + /// branch (and its own airborne "landing block" early return) while + /// still classifying as a remote (update.Guid != _playerServerGuid). + /// + private const uint OtherPlayerGuid = 0x50006001u; + private const float SpawnHeight = 7f; + /// + /// Retail's foot-sphere convention (PhysicsEngine.Resolve's + /// FootSphereCenterLift, #107 2026-06-10): a body's Z sits this + /// far above the terrain sample its foot sphere rests on, center-to- + /// contact. A canonical placement onto flat terrain therefore resolves + /// to terrainHeight + FootSphereCenterLift, not the raw wire Z. + /// + private const float FootSphereCenterLift = 0.48f; + + /// + /// The strong form of test-plan item 9's commit half: after a remote + /// teleport commits, the render pose equals the + /// resolved body pose, ParentCellId equals the resolved cell, the + /// entity is spatially visible, and the collision shadow was published + /// (re-synced) at the resolved position — not merely registered once at + /// spawn. Fails against a broken implementation that skips the teleport + /// arm's render/shadow tail (invariant 2): reverting that tail to a + /// no-op leaves Entity.Position at the pre-teleport spawn pose and + /// the shadow registry's position unsynced, both asserted below. + /// + [Fact] + public void TeleportCommit_RenderEntityMatchesResolvedBody_VisibleAndShadowSynced() + { + using var fixture = new Fixture(); + fixture.PublishDestinationCollision(); + fixture.ServiceWindow.Allow(DestinationLandblock); + Vector3 spawnPose = fixture.Entity.Position; + var destination = new Vector3(12f, 14f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.TeleportUpdate( + destination, DestinationCell, teleportSequence: 5)); + + Assert.True(fixture.Lifetime.Entities.TryGetActive( + RemoteGuid, out RuntimeEntityRecord canonical)); + Assert.NotNull(canonical.PhysicsBody); + PhysicsBody body = canonical.PhysicsBody!; + Vector3 resolved = destination + DestinationWorldOffset + + new Vector3(0f, 0f, FootSphereCenterLift); + Assert.Equal(resolved, body.Position); + Assert.NotEqual(spawnPose, body.Position); + + // Invariant 2: the render entity advances from the RESOLVED body. + Assert.Equal(body.Position, fixture.Entity.Position); + Assert.Equal(body.Orientation, fixture.Entity.Rotation); + Assert.Equal(DestinationCell, fixture.Entity.ParentCellId); + Assert.Equal(DestinationCell, canonical.FullCellId); + + Assert.True(fixture.Runtime.TryGetRecord( + RemoteGuid, out LiveEntityRecord liveRecord)); + Assert.True(liveRecord.IsSpatiallyVisible); + + // The collision shadow was RE-PUBLISHED at the resolved position — + // not merely the stale spawn-time registration. + AcDream.Core.Physics.ShadowEntry shadowEntry = Assert.Single( + fixture.Shadows.AllEntriesForDebug(), + entry => entry.EntityId == fixture.Entity.Id); + Assert.Equal(body.Position, shadowEntry.Position); + + fixture.DrainPlacementFifo(); + } + + /// + /// Test-plan item 9's refused half: invariant 1 (the pose still + /// advances) restated at the render layer, plus the #312-specific claim + /// — the entity REMAINS visible. Fails against a regression that leaves + /// the render entity at its stale pose or withdraws it from the spatial + /// working set on a refusal. + /// + /// + /// A4 fix round (2026-08-04): every prior assertion here was ALSO + /// satisfied by a regression that misclassified this packet as + /// UnroutedCatchUp instead of the teleport arm — its + /// ApplyInterpolate hard-place lands the body at the exact same + /// wire pose (AP-87's bodyToTarget > 4 m branch) and the SAME + /// NPC tail then syncs entity/shadow identically, so none of those + /// assertions could tell the two arms apart. Arming sticky BEFORE the + /// packet and asserting it was cleared afterward closes that gap: + /// UnroutedCatchUp never reaches ApplyRemoteContactRouting's + /// teleport branch at all, so it never runs the hook, so a stuck NPC + /// misclassified onto that arm would stay stuck — only the real teleport + /// arm's hook calls UnStick. + /// + /// + [Fact] + public void TeleportRefused_RenderPoseTracksStoredDestination_EntityRemainsVisible() + { + using var fixture = new Fixture(); + fixture.PublishDestinationCollision(); + // Deliberately NOT ServiceWindow.Allow(DestinationLandblock) — the + // canonical placement refuses, so store_position's fallback is what + // must move the body (and the render entity behind it). + Vector3 spawnPose = fixture.Entity.Position; + var destination = new Vector3(12f, 14f, SpawnHeight); + EntityPhysicsHost host = fixture.ArmSticky(stickTargetGuid: 0x70009998u); + + fixture.Controller.OnPosition(fixture.TeleportUpdate( + destination, DestinationCell, teleportSequence: 5)); + + // The discriminator: only the real teleport arm's hook clears this. + Assert.Equal(0u, host.PositionManager.GetStickyObjectId()); + + Assert.True(fixture.Lifetime.Entities.TryGetActive( + RemoteGuid, out RuntimeEntityRecord canonical)); + PhysicsBody body = canonical.PhysicsBody!; + // Unlike the commit half, a REFUSED placement never runs the + // ground-contact sweep that applies FootSphereCenterLift — the + // store_position fallback stamps the raw wire position verbatim. + Vector3 resolved = destination + DestinationWorldOffset; + Assert.Equal(resolved, body.Position); + Assert.NotEqual(spawnPose, body.Position); + + Assert.Equal(body.Position, fixture.Entity.Position); + + Assert.True(fixture.Runtime.TryGetRecord( + RemoteGuid, out LiveEntityRecord liveRecord)); + Assert.True(liveRecord.IsSpatiallyVisible); + + // The collision shadow was RE-PUBLISHED at the stored destination — + // the generic top-of-OnPosition wire-pose write never touches the + // shadow registry, so this specifically exercises the tail. + AcDream.Core.Physics.ShadowEntry shadowEntry = Assert.Single( + fixture.Shadows.AllEntriesForDebug(), + entry => entry.EntityId == fixture.Entity.Id); + Assert.Equal(body.Position, shadowEntry.Position); + + fixture.DrainPlacementFifo(); + } + + /// + /// Test-plan item 6 / contract D5: TS-44's sticky suppression is an + /// NPC-only CALLER gate that must NOT suppress the teleport arm — retail + /// sticky cannot survive a teleport (UnStick is + /// teleport_hook's second action, @0x00514EEE). The exact gate + /// under test is + /// LiveEntityNetworkUpdateController.OnPosition's + /// if (!snapSuppressedByStick || isTeleportRoute) — a stuck NPC's + /// teleport packet must still run the hook (observable here as + /// UnStick actually clearing the sticky lease) and place + /// canonically, exactly as an unstuck NPC's would. + /// + [Fact] + public void StuckNpc_TeleportPacket_RunsTheHookAndPlacesDespiteStickySuppression() + { + using var fixture = new Fixture(); + fixture.PublishDestinationCollision(); + fixture.ServiceWindow.Allow(DestinationLandblock); + EntityPhysicsHost host = fixture.ArmSticky(stickTargetGuid: 0x70009999u); + var destination = new Vector3(12f, 14f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.TeleportUpdate( + destination, DestinationCell, teleportSequence: 5)); + + // The hook's UnStick action actually ran — proof the sticky lease + // did not block dispatch, not merely that the placement happened to + // succeed for some unrelated reason. + Assert.Equal(0u, host.PositionManager.GetStickyObjectId()); + + Assert.True(fixture.Lifetime.Entities.TryGetActive( + RemoteGuid, out RuntimeEntityRecord canonical)); + Assert.NotNull(canonical.PhysicsBody); + PhysicsBody body = canonical.PhysicsBody!; + Vector3 resolved = destination + DestinationWorldOffset + + new Vector3(0f, 0f, FootSphereCenterLift); + Assert.Equal(resolved, body.Position); + Assert.Equal(DestinationCell, canonical.FullCellId); + + fixture.DrainPlacementFifo(); + } + + /// + /// Test-plan item 6 / contract D5, the OnPosition-level restatement: + /// "the player arm's landing block (!rmState.Body.InContact + /// hard-snap + return) must not claim a teleport-classified packet" and + /// "the 4a IsAirborneNoOperation early returns... cannot claim a + /// SetPosition route". takes + /// OnPosition's IsPlayerGuid branch (a DIFFERENT code path + /// than every other test in this file, which use the creature-guid + /// branch) — an ungrounded wire packet (IsGrounded: false) for a + /// teleport-classified body must still reach the canonical placement + /// through that branch's own copy of ApplyRemoteContactRouting, + /// not the branch's airborne early return at the top of the + /// IsPlayerGuid block. + /// + [Fact] + public void AirborneOtherPlayer_TeleportPacket_PlacesThroughThePlayerArmWithoutTheLandingBlock() + { + using var fixture = new Fixture(OtherPlayerGuid); + fixture.PublishDestinationCollision(); + fixture.ServiceWindow.Allow(DestinationLandblock); + // Same mid-arc state the routing-seam test above uses: no wire + // contact, no body contact — the strongest form of "airborne". + fixture.Remote.Airborne = true; + fixture.Remote.Body.TransientState = TransientStateFlags.Active; + var destination = new Vector3(12f, 14f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.TeleportUpdate( + destination, + DestinationCell, + teleportSequence: 5, + guid: OtherPlayerGuid, + isGrounded: false)); + + Assert.True(fixture.Lifetime.Entities.TryGetActive( + OtherPlayerGuid, out RuntimeEntityRecord canonical)); + Assert.NotNull(canonical.PhysicsBody); + PhysicsBody body = canonical.PhysicsBody!; + Vector3 resolved = destination + DestinationWorldOffset + + new Vector3(0f, 0f, FootSphereCenterLift); + Assert.Equal(resolved, body.Position); + Assert.Equal(DestinationCell, canonical.FullCellId); + // The airborne early return writes NOTHING and returns before the + // spatial rebucket that follows a real dispatch — resident + // visibility is proof OnPosition did not take that exit. + Assert.True(fixture.Runtime.TryGetRecord( + OtherPlayerGuid, out LiveEntityRecord liveRecord)); + Assert.True(liveRecord.IsSpatiallyVisible); + + fixture.DrainPlacementFifo(); + } + + /// + /// A1 fix round (2026-08-04): the NPC arm must still arm the leash for + /// an ordinary landing packet — wire IS grounded (retail's + /// arg4 != 0), body is NOT in contact (acdream's own + /// snap-vs-interpolate axis, unrelated to retail's arming predicate). + /// 's + /// free-flight carve-out claims this as AirborneSnap regardless of + /// classification (near/far/leftover would all otherwise apply); before + /// the fix, ToConstraintArm mapped AirborneSnap to the one + /// Runtime value that never arms, so this exact packet — a creature + /// knocked off a ledge — armed ZERO times where retail's + /// MoveOrTeleport returns nonzero and arms unconditionally + /// @0x00454272. host.PositionManager.Constraint is lazily created + /// only on a genuine arm, so its presence after the packet is direct, + /// unambiguous proof of the fix — not an inference from body position. + /// + [Fact] + public void NpcAirborneSnap_LandingPacket_StillArmsTheLeash() + { + using var fixture = new Fixture(); + EntityPhysicsHost host = fixture.InstallHost(); + Assert.Null(host.PositionManager.Constraint); + // Mid-arc: body not in contact, wire IS grounded (this packet is an + // ordinary landing correction, not a free-flight UP). + fixture.Remote.Airborne = true; + fixture.Remote.Body.TransientState = TransientStateFlags.Active; + var landingPos = new Vector3(12f, 14f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.TeleportUpdate( + landingPos, SourceCell, teleportSequence: 1, isGrounded: true)); + + Assert.NotNull(host.PositionManager.Constraint); + } + + /// + /// R3/A2 fix round (2026-08-04): a teleported NPC must not synthesize a + /// locomotion velocity from the teleport distance. Before the fix, the + /// deleted shared remotePlacementRequired block used to sit ABOVE + /// the NPC synth-velocity code and always returned, so a teleport packet + /// never reached it; once that block was deleted and the teleport arm + /// routed through the NPC tail, the synth-velocity install ran + /// unconditionally, computing (worldPos - LastServerPos) / elapsed + /// across the WHOLE teleport distance over one packet interval — + /// hundreds to thousands of m/s — and installing it as + /// ServerVelocity. 's + /// LastServerPos/LastServerPosTime are seeded to simulate + /// an already-tracked creature (the real @teleto-on-a-visible- + /// drudge scenario) so a broken implementation's synth would have a real + /// distance/interval to compute from, not a degenerate first-packet + /// no-op. + /// + [Fact] + public void NpcTeleport_DoesNotInstallASynthesizedVelocity() + { + using var fixture = new Fixture(); + fixture.PublishDestinationCollision(); + fixture.ServiceWindow.Allow(DestinationLandblock); + fixture.Remote.LastServerPos = fixture.Entity.Position; + fixture.Remote.LastServerPosTime = + (DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds - 0.15; + var destination = new Vector3(12f, 14f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.TeleportUpdate( + destination, DestinationCell, teleportSequence: 5)); + + Assert.False(fixture.Remote.HasServerVelocity); + Assert.Equal(Vector3.Zero, fixture.Remote.ServerVelocity); + + fixture.DrainPlacementFifo(); + } + + /// + /// Test-plan item 10 (R1/A7 fix round, 2026-08-04): "a wire-airborne + /// null-classified packet writes exactly AP-135's two fields and nothing + /// else (body, entity, queue, leash all untouched)" — driven for the NPC + /// arm specifically, which shipped WITHOUT this D2 shape (the player arm + /// had it from the start). Before the fix, a wire-airborne, null/ + /// Rejected*-classified NPC packet fell through to + /// 's + /// own free-flight carve-out and received a hard-snap body write, a + /// leash arm, and a shadow republish — none of which retail's + /// return 0 (@0x0051636D) produces. + /// forces the null-classification shape (no local player exists yet to + /// supply player_distance — the login-window case D2 is scoped + /// to); teleportSequence: 1 matches the spawn's own committed + /// teleport timestamp so this packet does NOT advance TELEPORT_TS + /// (never teleport-classified, even setting classification aside). + /// + [Fact] + public void NullClassifiedNpc_WireAirbornePacket_WritesOnlyBookkeepingNoBodyOrShadow() + { + using var fixture = new Fixture(nullClassification: true); + EntityPhysicsHost host = fixture.InstallHost(); + Assert.Null(host.PositionManager.Constraint); + Vector3 spawnBodyPose = fixture.Remote.Body.Position; + var wirePos = new Vector3(50f, 50f, SpawnHeight); + + fixture.Controller.OnPosition(fixture.TeleportUpdate( + wirePos, + SourceCell, + teleportSequence: 1, + isGrounded: false)); + + // D2's core claim: no body write. A broken implementation that lets + // this packet reach ApplyRemoteContactRouting's free-flight carve-out + // hard-snaps the body to wirePos here. + Assert.Equal(spawnBodyPose, fixture.Remote.Body.Position); + + // No shadow republish — still exactly the one spawn-time entry, at + // the spawn pose, not wirePos. + AcDream.Core.Physics.ShadowEntry shadowEntry = Assert.Single( + fixture.Shadows.AllEntriesForDebug(), + entry => entry.EntityId == fixture.Entity.Id); + Assert.Equal(spawnBodyPose, shadowEntry.Position); + + // Round-2 architecture review B1: everything above asserts only what + // must NOT happen, so an EMPTIED ApplyWireAirborneLeftoverBookkeeping + // passed every test in the tree. AP-135's two writes are the POSITIVE + // half of D2's shape and are load-bearing: RemoteMotion.CellId feeds + // RuntimeRemotePhysicsUpdater's free-fall sweep gate (its `rm.CellId + // != 0` test) and the LastServerPos/LastServerPosTime pair seeds the + // first-grounded-packet velocity synthesis. Losing them silently is + // the "airborne remote falls through the floor" shape. + Assert.Equal(SourceCell, fixture.Remote.CellId); + Assert.Equal(wirePos, fixture.Remote.LastServerPos); + Assert.NotEqual(0d, fixture.Remote.LastServerPosTime); + + // Round-2 retail review: this test's name and doc both claim the leash + // is untouched — a D4 "no arm" row, because retail returns 0 + // @0x0051636D and never reaches ConstrainTo @0x00454272 — but nothing + // asserted it. The constraint sub-manager is lazily created only + // inside a genuine arm, so null is direct proof of zero arming calls. + Assert.Null(host.PositionManager.Constraint); + } + + private sealed class Fixture : IDisposable + { + internal RuntimeEntityObjectLifetime Lifetime { get; } + internal LiveEntityRuntime Runtime { get; } + internal LiveEntityNetworkUpdateController Controller { get; } + internal RemoteServiceWindow ServiceWindow { get; } = new(); + internal ShadowObjectRegistry Shadows { get; } + internal WorldEntity Entity { get; } + internal RemoteMotion Remote { get; private set; } = null!; + private readonly GpuWorldState _spatial; + private readonly uint _guid; + private readonly bool _nullClassification; + + /// + /// Defaults to (an ordinary creature-style + /// guid, NOT in IsPlayerGuid's 0x50xxxxxx range). + /// Tests that need the OTHER production remote branch — the + /// IsPlayerGuid(update.Guid) block in OnPosition, which + /// has its own airborne early return (the "player arm's landing + /// block") — pass a guid in that range instead. + /// + internal Fixture(uint guid = RemoteGuid, bool nullClassification = false) + { + _guid = guid; + _nullClassification = nullClassification; + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + SourceLandblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + Lifetime = new RuntimeEntityObjectLifetime(engine); + Lifetime.BindEventContext( + static () => new RuntimeGenerationToken(1UL), + static () => 1UL); + Shadows = engine.ShadowObjects; + + var spatial = new GpuWorldState(); + // GpuWorldState's own bucket key is the CANONICAL landblock form + // ((id & 0xFFFF0000) | 0xFFFF) — the same form + // RebucketLiveEntity/RuntimeEntityObjectLifetime derive from a + // cell id. Registering the raw 0x....0000 id here would silently + // leave the entity in the PENDING bucket forever. + spatial.AddLandblock(new LoadedLandblock( + CanonicalLandblock(SourceLandblock), + new DatReaderWriter.DBObjs.LandBlock(), + Array.Empty())); + _spatial = spatial; + Runtime = new LiveEntityRuntime( + spatial, + new NoopResources(), + NullLiveEntityRuntimeComponentLifecycle.Instance, + Lifetime); + + var wirePosition = new CreateObject.ServerPosition( + SourceCell, 10f, 10f, SpawnHeight, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 1, + ServerControlledMove: 1, + ForcePosition: 1, + ObjDesc: 1, + Instance: 1); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.ReportCollisions, + Position: wirePosition, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: 1f, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + var spawn = new WorldSession.EntitySpawn( + _guid, + wirePosition, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "remote-teleport-fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + PositionSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + Physics: physics); + LiveEntityRecord record = + Runtime.RegisterAndMaterializeProjection(spawn); + Entity = record.WorldEntity + ?? throw new InvalidOperationException( + "fixture failed to materialize the remote entity"); + Assert.True(Runtime.RebucketLiveEntity(_guid, SourceCell)); + + // Adopts this body as the canonical PhysicsBody (none exists yet) + // — mirrors LiveEntityLifecycleStressTests' RecallPortalFixture. + var remote = new RemoteMotion(); + remote.Body.SnapToCell(SourceCell, Entity.Position, Entity.Position); + remote.CellId = SourceCell; + Runtime.SetRemoteMotionRuntime(_guid, remote); + Remote = remote; + Shadows.Register( + Entity.Id, + 0x02000001u, + Entity.Position, + Entity.Rotation, + radius: 0.48f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: SourceLandblock, + collisionType: ShadowCollisionType.Cylinder, + cylHeight: 1.835f, + seedCellId: SourceCell, + isStatic: false); + + var origin = new LiveWorldOriginState(); + origin.SetPlaceholder( + (int)((SourceLandblock >> 24) & 0xFFu), + (int)((SourceLandblock >> 16) & 0xFFu)); + + var animatedEntities = + new LiveEntityAnimationRuntimeView( + new LiveEntityRuntimeSlot()); + var remotePlacementDrive = new RuntimeRemotePlacementDriveController( + Lifetime, + new GameRuntimeClock(), + new NoopCollisionSource(), + ServiceWindow); + var acceptedPositionDrive = new RuntimeAcceptedPositionDriveController( + Lifetime, + new GameRuntimeClock(), + new NoopCollisionSource(), + new LocalPlayerOutboundController(static (_, _, _, _, _, _) => { }), + static () => new RuntimeGenerationToken(1UL), + static () => 0x50000099u, + static () => null, + static () => false, + static () => null); + var identity = new NoopIdentitySource(); + var deletion = new LiveEntityDeletionController( + Runtime, + Lifetime, + new NoopTeardownCoordinator(), + identity); + var hydration = new LiveEntityHydrationController( + Runtime, + Lifetime, + new object(), + new NoopMaterializer(), + new NoopRelationships(), + new NoopReadyPublisher(), + new AlwaysKnownOrigin(), + new NoopNetworkSink(), + new NoopTimestampPublisher(), + identity, + deletion); + var entityEffects = new EntityEffectController( + Runtime, + new AcDream.Core.Vfx.PhysicsScriptRunner( + static _ => null, + new AcDream.Core.Physics.AnimationHookRouter(), + randomUnit: static () => 0.5), + new AcDream.Core.Vfx.PhysicsScriptTableResolver(static _ => null), + new EntityEffectPoseRegistry()); + + Controller = new LiveEntityNetworkUpdateController( + Runtime, + Lifetime.Objects, + hydration, + entityEffects, + new LiveEntityPresentationController( + Runtime, + Shadows, + (_, _, _) => true, + new LiveEntityPartArrayEnterWorldPort(_ => { })), + new LiveEntityLightController( + Runtime, + new EntityEffectPoseRegistry(), + new AcDream.Core.Lighting.LightingHookSink( + new AcDream.Core.Lighting.LightManager(), + new EntityEffectPoseRegistry()), + static _ => null), + new EquippedChildRenderController( + new NoopDatReaderWriter(), + new object(), + Lifetime.Objects, + Runtime, + new EntityEffectPoseRegistry(), + static _ => false, + static (_, _, _) => + new ExactProjectionWithdrawalOutcome( + ExactProjectionWithdrawalDisposition.Superseded, + null)), + new ProjectileController(Runtime), + animatedEntities, + new RemoteMovementObservationTracker(), + new RemotePhysicsUpdater( + Lifetime.Physics, + static (_, _) => (0.48f, 1.835f), + static (_, _) => ( + System.Collections.Immutable + .ImmutableArray.Empty, + 1f, 0.4f, 0.4f), + static (_, _, _, _) => { }), + new RemoteInboundMotionDispatcher( + static (_, _, _) => false, + static (_, _) => { }), + new LiveEntityMotionRuntimeController( + Runtime, + new PhysicsDataCache(), + static () => null, + new AcDream.Core.Selection.SelectionState(), + origin), + engine, + new NoopDatReaderWriter(), + new NoopAnimationLoader(), + combatTargetController: null, + origin, + new NoopTeleportSink(), + _nullClassification + ? new NoopLocalPlayerControllerSource() + : new StubLocalPlayerControllerSource(), + new LocalPlayerOutboundController(static (_, _, _, _, _, _) => { }), + new NoopPhysicsHostSource(), + identity, + new FixedScriptTime(), + new NoopSessionSource(), + publishTimestamps: static (_, _) => { }, + new NoopMovementTruthSink(), + acceptedPositionDrive, + remotePlacementDrive, + worldDropProjection: null); + } + + internal void PublishDestinationCollision() + { + var heights = new byte[81]; + Array.Fill(heights, (byte)SpawnHeight); + var heightTable = new float[256]; + for (int index = 0; index < heightTable.Length; index++) + heightTable[index] = index; + Lifetime.Physics.ObserveLocalWorldFrame( + SourceCell, teleportAdvanced: false); + Lifetime.Physics.SetPosition.BeginCollisionGeneration( + DestinationLandblock, 1UL); + Lifetime.Physics.Engine.AddLandblock( + DestinationLandblock, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: DestinationWorldOffset.X, + worldOffsetY: DestinationWorldOffset.Y); + Lifetime.Physics.SetPosition.CommitCollisionGeneration( + DestinationLandblock, 1UL, ready: true); + + // The destination landblock must also be a LOADED spatial bucket + // — not merely collision-ready — or IsLiveEntityProjectionResident + // would (correctly) report the entity as not-yet-streamed-in and + // mask the render-layer assertions these tests exist to make. + // Streaming residency itself is out of scope here; C4 route 4b-3 + // is about the physics/render sync tail once a destination is + // already resident, matching the two-tier streaming contract. + uint destinationCanonical = CanonicalLandblock(DestinationLandblock); + if (!_spatial.IsLoaded(destinationCanonical)) + { + _spatial.AddLandblock(new LoadedLandblock( + destinationCanonical, + new DatReaderWriter.DBObjs.LandBlock(), + Array.Empty())); + } + } + + private static uint CanonicalLandblock(uint landblockId) => + (landblockId & 0xFFFF0000u) | 0xFFFFu; + + internal WorldSession.EntityPositionUpdate TeleportUpdate( + Vector3 destination, + uint cellId, + ushort teleportSequence, + uint guid = RemoteGuid, + bool isGrounded = true) => new( + guid, + new CreateObject.ServerPosition( + cellId, + destination.X, + destination.Y, + destination.Z, + 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: isGrounded, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: teleportSequence, + ForcePositionSequence: 0); + + /// + /// Item 1(b): installs a real on the + /// remote's canonical incarnation and arms its sticky lease, exactly + /// as the production hydration pipeline does before a live NPC can + /// stick to a target. RunRemoteTeleportHook resolves the host + /// through _liveEntities.TryGetPhysicsHost, so a test that + /// wants the hook's UnStick action to be observable needs the + /// SAME installed host, not merely a bound + /// reader. + /// + internal EntityPhysicsHost ArmSticky(uint stickTargetGuid) + { + EntityPhysicsHost host = InstallHost(); + host.PositionManager.StickTo(stickTargetGuid, radius: 1f, height: 1f); + Assert.NotEqual(0u, host.PositionManager.GetStickyObjectId()); + return host; + } + + /// + /// A1 fix round (2026-08-04): installs a real + /// WITHOUT arming sticky, so a test + /// can observe whether TryArmConstraintAfterOperation actually + /// armed the leash via host.PositionManager.Constraint — the + /// constraint sub-manager is lazily created only on a real arm, so + /// after a packet is direct proof of zero + /// arming calls. + /// + internal EntityPhysicsHost InstallHost() + { + Assert.True(Runtime.TryGetRecord( + _guid, out LiveEntityRecord liveRecord)); + var host = new EntityPhysicsHost( + _guid, + getPosition: () => new AcDream.Core.Physics.Position( + Remote.CellId, Remote.Body.Position, Remote.Body.Orientation), + getVelocity: () => Remote.Body.Velocity, + getRadius: () => 0.48f, + inContact: () => Remote.Body.InContact, + minterpMaxSpeed: () => null, + curTime: () => 0d, + physicsTimerTime: () => 0d, + getObjectA: _ => null, + handleUpdateTarget: _ => { }, + interruptCurrentMovement: () => { }); + Runtime.InstallPhysicsHost(liveRecord, host); + Remote.MarkFullPhysicsHostBound(); + return host; + } + + internal void DrainPlacementFifo() + { + while (Lifetime.Physics.SetPosition.TryPeekProjection( + out RuntimePlacementProjectionSnapshot head)) + { + if (!Lifetime.Physics.SetPosition.AcknowledgeProjection(head.Token)) + break; + } + } + + public void Dispose() => Lifetime.Dispose(); + + internal sealed class RemoteServiceWindow : IRuntimeRemotePlacementServiceWindow + { + private readonly HashSet _within = []; + + internal void Allow(uint landblockId) => + _within.Add((landblockId & 0xFFFF0000u) | 0xFFFFu); + + public bool IsWithinServiceWindow(uint landblockId) => + _within.Contains((landblockId & 0xFFFF0000u) | 0xFFFFu); + } + + private sealed class NoopResources : ILiveEntityResourceLifecycle + { + public void Register(WorldEntity entity) { } + public void Unregister(WorldEntity entity) { } + } + + private sealed class NoopCollisionSource : IPreparedCollisionSource + { + public PreparedAssetPresence ProbeCollision( + PakAssetType type, uint sourceFileId) => + PreparedAssetPresence.Available; + + public PreparedCollisionReadResult + ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + PreparedCollisionReadResult.Loaded( + new FlatSetupCollision( + System.Collections.Immutable + .ImmutableArray.Empty, + [new FlatCollisionSphere(Vector3.Zero, 0.48f)], + height: 0f, + radius: 0f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f)); + + public PreparedCollisionReadResult + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public PreparedCollisionSourceStats CollisionStats => default; + + public void Dispose() { } + } + + private sealed class NoopIdentitySource : ILocalPlayerIdentitySource + { + public uint ServerGuid => 0x50000099u; + } + + private sealed class NoopTeardownCoordinator + : ILiveEntityTeardownCoordinator + { + public void TearDown(LiveEntityRecord record) { } + public void ForgetUnknownOwner(uint serverGuid) { } + } + + private sealed class NoopMaterializer : ILiveEntityProjectionMaterializer + { + public bool TryMaterialize( + RuntimeEntityRecord expectedCanonical, + WorldSession.EntitySpawn canonicalSpawn, + LiveProjectionPurpose purpose, + ulong expectedCreateIntegrationVersion, + AcDream.App.Rendering.LiveEntityAppearanceUpdateState? + appearanceUpdate = null) => + throw new InvalidOperationException( + "The fixture pre-materializes the remote entity; " + + "TryMaterialize should never be reached for an " + + "already-projected accepted Position."); + + public void ResetSessionState() { } + } + + private sealed class NoopRelationships : ILiveEntityRelationshipProjection + { + public void OnSpawn(WorldSession.EntitySpawn spawn) { } + public void OnParent(ParentEvent.Parsed update) { } + public void OnCreateParentAccepted(CreateParentUpdate update) { } + + public AcDream.App.Rendering.ChildUnparentDisposition + OnChildBecameUnparented(uint childGuid) => + AcDream.App.Rendering.ChildUnparentDisposition.NotAttached; + + public bool TryApplyAttachedAppearance( + LiveEntityRecord record, ulong objDescAuthorityVersion) => false; + } + + private sealed class NoopReadyPublisher : ILiveEntityReadyPublisher + { + public bool Publish(LiveEntityReadyCandidate candidate) => true; + } + + private sealed class AlwaysKnownOrigin : ILiveEntityWorldOriginCoordinator + { + public bool IsKnown => true; + + public LiveEntityOriginInitialization TryInitialize( + WorldSession.EntitySpawn spawn) => new(true, []); + } + + private sealed class NoopNetworkSink : ILiveEntityNetworkUpdateSink + { + public void ApplySameGeneration(SameGenerationCreateObjectEvents events) { } + } + + private sealed class NoopTimestampPublisher + : IAcceptedLocalPhysicsTimestampPublisher + { + public void Publish(uint serverGuid, AcceptedPhysicsTimestamps timestamps) { } + } + + private sealed class NoopDatReaderWriter : IDatReaderWriter + { + private readonly StubDatabase _portal = new(); + private readonly StubDatabase _highRes = new(); + private readonly StubDatabase _language = new(); + private readonly StubDatabase _cell = new(); + + public string SourceDirectory => string.Empty; + public IDatDatabase Portal => _portal; + public IDatDatabase Cell => _cell; + public ReadOnlyDictionary CellRegions { get; } = + new(new Dictionary()); + public IDatDatabase HighRes => _highRes; + public IDatDatabase Language => _language; + public IDatDatabase Local => _language; + public ReadOnlyDictionary RegionFileMap { get; } = + new(new Dictionary()); + public int PortalIteration => 0; + public int CellIteration => 0; + public int HighResIteration => 0; + public int LanguageIteration => 0; + + public bool TryGetFileBytes( + uint regionId, + uint fileId, + ref byte[] bytes, + out int bytesRead) + { + bytesRead = 0; + return false; + } + + public IEnumerable GetAllIdsOfType() where T : IDBObj => + Array.Empty(); + + public IEnumerable ResolveId(uint id) => + Array.Empty(); + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + public bool TrySave( + uint regionId, + T obj, + int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + [return: MaybeNull] + public T Get(uint fileId) where T : IDBObj => default; + + public bool TryGet( + uint fileId, + [MaybeNullWhen(false)] out T value) where T : IDBObj + { + value = default; + return false; + } + + public void Dispose() { } + } + + private sealed class StubDatabase : IDatDatabase + { + public DatDatabase Db => throw new NotSupportedException(); + public int Iteration => 0; + + public IEnumerable GetAllIdsOfType() where T : IDBObj => + Array.Empty(); + + public bool TryGet( + uint fileId, + [MaybeNullWhen(false)] out T value) where T : IDBObj + { + value = default; + return false; + } + + public bool TryGetFileBytes( + uint fileId, + [MaybeNullWhen(false)] out byte[] value) + { + value = null; + return false; + } + + public bool TryGetFileBytes( + uint fileId, + ref byte[] bytes, + out int bytesRead) + { + bytesRead = 0; + return false; + } + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + public void Dispose() { } + } + + private sealed class NoopAnimationLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } + + private sealed class NoopTeleportSink : ILocalPlayerTeleportNetworkSink + { + public void OnTeleportStarted(uint sequence) { } + + public void OfferDestination( + RuntimeTeleportDestination destination, + bool teleportTimestampAdvanced) + { } + + public void ResetSession() { } + + public void ResetGenerationPresentation() { } + } + + /// + /// A makes + /// ClassifyRemoteAcceptedPosition's player_distance + /// input null, which + /// + /// then refuses outright (no fabricated distance) — silently + /// starving every remote classification, including the teleport + /// arm this fixture exists to exercise. A real (if origin-parked) + /// controller supplies a finite distance instead. + /// + private sealed class StubLocalPlayerControllerSource + : IRuntimeLocalPlayerControllerSource + { + public PlayerMovementController? Controller { get; } = + new PlayerMovementController(new PhysicsEngine()); + } + + /// + /// Fix round (2026-08-04): the deliberate INVERSE of + /// — a null + /// starves player_distance, which + /// makes ClassifyRemoteAcceptedPosition return a null route + /// (the exact "login window" shape D2 exists for), rather than a + /// real classification. Used ONLY by tests that specifically need a + /// null-classified packet. + /// + private sealed class NoopLocalPlayerControllerSource + : IRuntimeLocalPlayerControllerSource + { + public PlayerMovementController? Controller => null; + } + + private sealed class NoopPhysicsHostSource : ILocalPlayerPhysicsHostSource + { + public EntityPhysicsHost? Host => null; + } + + private sealed class FixedScriptTime : IPhysicsScriptTimeSource + { + public double CurrentScriptTime => 1_700_000_000d; + } + + private sealed class NoopSessionSource : ILiveWorldSessionSource + { + public WorldSession? CurrentSession => null; + } + + private sealed class NoopMovementTruthSink : IMovementTruthDiagnosticSink + { + public void OnOutbound( + string kind, + uint sequence, + MovementResult result, + Vector3 wirePosition, + uint wireCellId, + byte contactByte) + { } + + public void OnServerEcho( + WorldSession.EntityPositionUpdate update, + Vector3 serverWorldPosition) + { } + + public void ResetSession() { } + } + } +} diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs deleted file mode 100644 index ffd6cdfd..00000000 --- a/tests/AcDream.App.Tests/Physics/RemoteTeleportControllerTests.cs +++ /dev/null @@ -1,1515 +0,0 @@ -using System.Numerics; -using AcDream.App.Physics; -using AcDream.App.Rendering; -using AcDream.App.Streaming; -using AcDream.App.World; -using AcDream.Core.Net; -using AcDream.Core.Net.Messages; -using AcDream.Core.Physics; -using AcDream.Core.World; -using DatReaderWriter.DBObjs; - -namespace AcDream.App.Tests.Physics; - -public sealed class RemoteTeleportControllerTests -{ - private sealed class Resources : ILiveEntityResourceLifecycle - { - public void Register(WorldEntity entity) { } - public void Unregister(WorldEntity entity) { } - } - - private sealed class BodyOnlyRemote(PhysicsBody body) : IRuntimeRemoteMotion - { - public PhysicsBody Body { get; } = body; - } - - private sealed class MutablePlacementRemote(PhysicsBody body) : IRuntimeRemotePlacement - { - private uint _cellId; - private Func? _readCell; - private Action? _writeCell; - - public PhysicsBody Body { get; set; } = body; - public uint CellId - { - get => _readCell?.Invoke() ?? _cellId; - set - { - _cellId = value; - _writeCell?.Invoke(value); - } - } - public bool Airborne { get; set; } - public Vector3 LastServerPosition { get; set; } - public double LastServerPositionTime { get; set; } - public Vector3 LastShadowSyncPosition { get; set; } - public Quaternion LastShadowSyncOrientation { get; set; } - public void BindCanonicalCell(Func read, Action write) - { - _readCell = read; - _writeCell = write; - } - public void HitGround() { } - public void LeaveGround() { } - } - - private sealed class AlternatingBodyRemote( - PhysicsBody first, - PhysicsBody later) : IRuntimeRemotePlacement - { - private int _reads; - public PhysicsBody Body => _reads++ == 0 ? first : later; - public uint CellId { get; set; } - public bool Airborne { get; set; } - public Vector3 LastServerPosition { get; set; } - public double LastServerPositionTime { get; set; } - public Vector3 LastShadowSyncPosition { get; set; } - public Quaternion LastShadowSyncOrientation { get; set; } - public void BindCanonicalCell(Func read, Action write) { } - public void HitGround() { } - public void LeaveGround() { } - } - - [Fact] - public void PendingDestination_ParksAuthoritativeFrameThenResolvesContactOnLoad() - { - const uint guid = 0x70000001u; - const uint sourceCell = 0xA9B40039u; - const uint destinationCell = 0xAAB40001u; - var spatial = new GpuWorldState(); - spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); - var live = LiveEntityRuntimeFixture.Create(spatial, new Resources()); - live.RegisterLiveEntity(Spawn(guid, sourceCell)); - WorldEntity entity = live.MaterializeLiveEntity( - guid, - sourceCell, - id => new WorldEntity - { - Id = id, - ServerGuid = guid, - SourceGfxObjOrSetupId = 0x02000001u, - Position = new Vector3(191f, 10f, 50f), - Rotation = Quaternion.Identity, - MeshRefs = Array.Empty(), - })!; - var remote = new AcDream.Runtime.Physics.RemoteMotion(); - remote.Body.SnapToCell( - sourceCell, - entity.Position, - entity.Position); - remote.Body.TransientState = TransientStateFlags.Contact - | TransientStateFlags.OnWalkable; - live.SetRemoteMotionRuntime(guid, remote); - var engine = BuildEngine(includeDestination: false); - engine.ShadowObjects.Register( - entity.Id, - entity.SourceGfxObjOrSetupId, - entity.Position, - entity.Rotation, - radius: 0.48f, - worldOffsetX: 0f, - worldOffsetY: 0f, - landblockId: 0xA9B4FFFFu, - collisionType: ShadowCollisionType.Cylinder, - cylHeight: 1.835f, - seedCellId: sourceCell, - isStatic: false); - Assert.True(engine.ShadowObjects.Suspend(entity.Id)); - int shadowSyncs = 0; - using var controller = new RemoteTeleportController( - engine, - live, - (_, _) => (0.48f, 1.835f), - CellLocal, - (resolvedEntity, resolvedRemote, resolvedCell) => - { - shadowSyncs++; - engine.ShadowObjects.Register( - resolvedEntity.Id, - resolvedEntity.SourceGfxObjOrSetupId, - resolvedRemote.Position, - resolvedRemote.Orientation, - radius: 0.48f, - worldOffsetX: 0f, - worldOffsetY: 0f, - landblockId: 0xAAB4FFFFu, - collisionType: ShadowCollisionType.Cylinder, - cylHeight: 1.835f, - seedCellId: resolvedCell, - isStatic: false); - }, - (_, _, _) => { }, - (_, _) => { }); - - Assert.True(live.RebucketLiveEntity(guid, destinationCell)); - var result = controller.TryApply( - remote, - entity, - new Vector3(193f, 10f, 50f), - destinationCell, - new Vector3(1f, 10f, 50f), - Quaternion.Identity, - gameTime: 5.0, - destinationProjectionVisible: false, - generation: 1, - positionSequence: 1); - - Assert.True(result.Applied); - Assert.False(result.ContactResolved); - Assert.Equal(destinationCell, remote.CellId); - Assert.Equal(new Vector3(193f, 10f, 50f), entity.Position); - Assert.False(remote.Body.InContact); - Assert.Equal(0, engine.ShadowObjects.TotalRegistered); - Assert.True(live.TryGetRecord(guid, out LiveEntityRecord pending)); - Assert.False(pending.IsSpatiallyVisible); - - Assert.True(live.TryApplyPosition( - new WorldSession.EntityPositionUpdate( - guid, - new CreateObject.ServerPosition( - destinationCell, 2f, 10f, 50f, 1f, 0f, 0f, 0f), - null, - null, - true, - 1, - 2, - 0, - 0), - isLocalPlayer: false, - forcePositionRotation: null, - currentLocalVelocity: null, - out _, - out _, - out _)); - var latest = controller.TryApply( - remote, - entity, - new Vector3(194f, 10f, 50f), - destinationCell, - new Vector3(2f, 10f, 50f), - Quaternion.Identity, - gameTime: 6.0, - destinationProjectionVisible: false, - generation: 1, - positionSequence: 2); - Assert.True(latest.Applied); - Assert.False(latest.ContactResolved); - - AddDestination(engine); - spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu)); - - Assert.True(pending.IsSpatiallyVisible); - Assert.Equal(destinationCell, pending.FullCellId); - Assert.True(remote.Body.InContact); - Assert.True(remote.Body.OnWalkable); - Assert.False(remote.Airborne); - Assert.Equal(194f, entity.Position.X, 3); - Assert.Equal(remote.Body.Position, entity.Position); - Assert.Equal(1, shadowSyncs); - Assert.Equal(1, engine.ShadowObjects.TotalRegistered); - ShadowEntry restored = Assert.Single( - engine.ShadowObjects.AllEntriesForDebug()); - Assert.Equal(entity.Position, restored.Position); - } - - [Fact] - public void LoadedPlacementFailure_RestoresSuspendedShadowAtRetainedPose() - { - const uint guid = 0x70000002u; - const uint sourceCell = 0xA9B40039u; - var spatial = new GpuWorldState(); - spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); - var live = LiveEntityRuntimeFixture.Create(spatial, new Resources()); - live.RegisterLiveEntity(Spawn(guid, sourceCell)); - WorldEntity entity = live.MaterializeLiveEntity( - guid, - sourceCell, - id => new WorldEntity - { - Id = id, - ServerGuid = guid, - SourceGfxObjOrSetupId = 0x02000001u, - Position = new Vector3(10f, 10f, 50f), - Rotation = Quaternion.Identity, - MeshRefs = Array.Empty(), - })!; - var remote = new AcDream.Runtime.Physics.RemoteMotion(); - remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position); - live.SetRemoteMotionRuntime(guid, remote); - var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; - engine.ShadowObjects.Register( - entity.Id, - entity.SourceGfxObjOrSetupId, - entity.Position, - entity.Rotation, - 0.48f, - 0f, - 0f, - 0xA9B4FFFFu, - ShadowCollisionType.Cylinder, - 1.835f, - seedCellId: sourceCell, - isStatic: false); - Assert.True(engine.ShadowObjects.Suspend(entity.Id)); - int shadowSyncs = 0; - using var controller = new RemoteTeleportController( - engine, - live, - (_, _) => (0.48f, 1.835f), - CellLocal, - (resolvedEntity, resolvedRemote, resolvedCell) => - { - shadowSyncs++; - engine.ShadowObjects.Register( - resolvedEntity.Id, - resolvedEntity.SourceGfxObjOrSetupId, - resolvedRemote.Position, - resolvedRemote.Orientation, - 0.48f, - 0f, - 0f, - 0xA9B4FFFFu, - ShadowCollisionType.Cylinder, - 1.835f, - seedCellId: resolvedCell, - isStatic: false); - }, - (_, _, _) => { }, - (_, _) => { }, - resolvePlacement: (position, cell, _, _, _, _) => - new ResolveResult( - position, - cell, - IsOnGround: false, - Ok: false)); - - RemoteTeleportController.Result result = controller.TryApply( - remote, - entity, - new Vector3(20f, 20f, 50f), - requestedCellId: 0u, - requestedCellLocalPosition: Vector3.Zero, - requestedOrientation: Quaternion.Identity, - gameTime: 2.0, - destinationProjectionVisible: true, - generation: 1, - positionSequence: 1); - - Assert.False(result.Applied); - Assert.Equal(1, shadowSyncs); - Assert.Equal(1, engine.ShadowObjects.TotalRegistered); - Assert.Equal(new Vector3(10f, 10f, 50f), entity.Position); - } - - [Fact] - public void DeferredPlacementFailure_RollsBackProjectionBodyAndShadow() - { - const uint guid = 0x70000003u; - const uint sourceCell = 0xA9B40039u; - const uint destinationCell = 0xAAB40001u; - Vector3 sourcePosition = new(191f, 10f, 50f); - var spatial = new GpuWorldState(); - spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); - var live = LiveEntityRuntimeFixture.Create(spatial, new Resources()); - live.RegisterLiveEntity(Spawn(guid, sourceCell)); - WorldEntity entity = live.MaterializeLiveEntity( - guid, - sourceCell, - id => new WorldEntity - { - Id = id, - ServerGuid = guid, - SourceGfxObjOrSetupId = 0x02000001u, - Position = sourcePosition, - Rotation = Quaternion.Identity, - MeshRefs = Array.Empty(), - })!; - var remote = new AcDream.Runtime.Physics.RemoteMotion(); - remote.Body.SnapToCell(sourceCell, sourcePosition, sourcePosition); - remote.Body.TransientState = TransientStateFlags.Contact - | TransientStateFlags.OnWalkable; - remote.CellId = sourceCell; - live.SetRemoteMotionRuntime(guid, remote); - var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; - engine.ShadowObjects.Register( - entity.Id, - entity.SourceGfxObjOrSetupId, - sourcePosition, - Quaternion.Identity, - 0.48f, - 0f, - 0f, - 0xA9B4FFFFu, - ShadowCollisionType.Cylinder, - 1.835f, - seedCellId: sourceCell, - isStatic: false); - Assert.True(engine.ShadowObjects.Suspend(entity.Id)); - int shadowSyncs = 0; - using var controller = new RemoteTeleportController( - engine, - live, - (_, _) => (0.48f, 1.835f), - CellLocal, - (resolvedEntity, resolvedRemote, resolvedCell) => - { - shadowSyncs++; - engine.ShadowObjects.Register( - resolvedEntity.Id, - resolvedEntity.SourceGfxObjOrSetupId, - resolvedRemote.Position, - resolvedRemote.Orientation, - 0.48f, - 0f, - 0f, - 0xA9B4FFFFu, - ShadowCollisionType.Cylinder, - 1.835f, - seedCellId: resolvedCell, - isStatic: false); - }, - (_, _, _) => { }, - (_, _) => { }, - resolvePlacement: (position, cell, _, _, _, _) => - new ResolveResult( - position, - cell, - IsOnGround: false, - Ok: false)); - - Assert.True(live.RebucketLiveEntity(guid, destinationCell)); - RemoteTeleportController.Result parked = controller.TryApply( - remote, - entity, - new Vector3(193f, 10f, 50f), - destinationCell, - new Vector3(1f, 10f, 50f), - Quaternion.Identity, - gameTime: 5.0, - destinationProjectionVisible: false, - generation: 1, - positionSequence: 1); - Assert.True(parked.Applied); - Assert.True(controller.HasPending(guid)); - - // Rendering becomes resident without matching physics collision data. - // The visibility edge is the one resolution attempt; failure must - // restore the retained source instead of leaving a visible ghost. - spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu)); - - Assert.False(controller.HasPending(guid)); - Assert.Equal(sourcePosition, remote.Body.Position); - Assert.Equal(sourceCell, remote.CellId); - Assert.Equal(sourcePosition, entity.Position); - Assert.Equal(sourceCell, entity.ParentCellId); - Assert.True(remote.Body.InContact); - Assert.True(remote.Body.OnWalkable); - Assert.True(live.TryGetRecord(guid, out LiveEntityRecord restored)); - Assert.Equal(sourceCell, restored.FullCellId); - Assert.True(restored.IsSpatiallyVisible); - Assert.Equal(1, shadowSyncs); - Assert.Equal(1, engine.ShadowObjects.TotalRegistered); - } - - [Fact] - public void DeferredFailure_WithUnloadedSource_RestoresShadowWhenSourceReloads() - { - using var fixture = new RollbackFixture(); - Assert.True(fixture.ParkPending().Applied); - fixture.VisibilityEdges.Clear(); - - fixture.Spatial.RemoveLandblock(RollbackFixture.SourceLandblock); - fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); - - Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); - Assert.True(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord pendingSource)); - Assert.Equal(RollbackFixture.SourceCell, pendingSource.FullCellId); - Assert.False(pendingSource.IsSpatiallyVisible); - Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); - Assert.False(fixture.PresentationVisible); - Assert.Equal(new[] { true, false }, fixture.VisibilityEdges); - - fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.SourceLandblock)); - - Assert.True(pendingSource.IsSpatiallyVisible); - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.Equal(1, fixture.ShadowSyncs); - Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); - Assert.True(fixture.PresentationVisible); - Assert.Equal(new[] { true, false, true }, fixture.VisibilityEdges); - } - - [Fact] - public void DeferredFailure_WithCelllessSource_WithdrawsPresentationWithoutShadowRestore() - { - using var fixture = new RollbackFixture(celllessSource: true); - Assert.True(fixture.ParkPending().Applied); - Assert.False(fixture.PresentationVisible); - fixture.VisibilityEdges.Clear(); - - fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); - - Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord withdrawn)); - Assert.Equal(0u, withdrawn.FullCellId); - Assert.False(withdrawn.IsSpatiallyProjected); - Assert.False(withdrawn.IsSpatiallyVisible); - Assert.False(fixture.PresentationVisible); - Assert.Equal(0, fixture.ShadowSyncs); - Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); - Assert.Equal(new[] { true, false }, fixture.VisibilityEdges); - } - - [Fact] - public void DeferredFailure_WithLoadedSource_PublishesNoFalseRebucketPulse() - { - using var fixture = new RollbackFixture(); - Assert.True(fixture.ParkPending().Applied); - fixture.VisibilityEdges.Clear(); - - fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); - - Assert.Equal(new[] { true }, fixture.VisibilityEdges); - Assert.True(fixture.PresentationVisible); - Assert.Equal(RollbackFixture.SourcePosition, fixture.Entity.Position); - Assert.Equal(RollbackFixture.SourceCell, fixture.Entity.ParentCellId); - Assert.Equal(1, fixture.ShadowSyncs); - } - - [Fact] - public void NewerLoadedFailure_ReplacesPendingAndRestoresOriginalSource() - { - const uint newerCell = 0xABB40001u; - const uint newerLandblock = 0xABB4FFFFu; - using var fixture = new RollbackFixture(); - fixture.Spatial.AddLandblock(EmptyLandblock(newerLandblock)); - Assert.True(fixture.ParkPending().Applied); - - Assert.True(fixture.Live.TryApplyPosition( - new WorldSession.EntityPositionUpdate( - RollbackFixture.Guid, - new CreateObject.ServerPosition( - newerCell, 1f, 10f, 50f, 1f, 0f, 0f, 0f), - null, - null, - true, - 1, - 2, - 0, - 0), - isLocalPlayer: false, - forcePositionRotation: null, - currentLocalVelocity: null, - out _, - out _, - out _)); - fixture.VisibilityEdges.Clear(); - fixture.Controller.BeginPlacement(RollbackFixture.Guid, generation: 1); - Assert.True(fixture.Live.RebucketLiveEntity(RollbackFixture.Guid, newerCell)); - Assert.True(fixture.Controller.HasPending(RollbackFixture.Guid)); - Assert.Equal(new[] { true }, fixture.VisibilityEdges); - - RemoteTeleportController.Result failed = fixture.Controller.TryApply( - fixture.Remote, - fixture.Entity, - new Vector3(385f, 10f, 50f), - newerCell, - new Vector3(1f, 10f, 50f), - Quaternion.Identity, - gameTime: 6.0, - destinationProjectionVisible: true, - generation: 1, - positionSequence: 2); - - Assert.False(failed.Applied); - Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); - Assert.Equal(RollbackFixture.SourcePosition, fixture.Remote.Body.Position); - Assert.Equal(RollbackFixture.SourceCell, fixture.Remote.CellId); - Assert.True(fixture.Remote.Body.InContact); - Assert.True(fixture.Remote.Body.OnWalkable); - Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord restored)); - Assert.Equal(RollbackFixture.SourceCell, restored.FullCellId); - Assert.True(restored.IsSpatiallyVisible); - Assert.Equal(1, fixture.ShadowSyncs); - Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); - Assert.Equal(new[] { true }, fixture.VisibilityEdges); - } - - [Fact] - public void HiddenThenUnhideWhileRollbackSourcePending_RestoresShadowExactlyOnce() - { - using var fixture = new RollbackFixture(); - Assert.True(fixture.ParkPending().Applied); - fixture.Spatial.RemoveLandblock(RollbackFixture.SourceLandblock); - fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); - Assert.True(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - - Assert.True(fixture.Live.TryApplyState( - new SetState.Parsed( - RollbackFixture.Guid, - (uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions), - 1, - 2), - out _, - out _)); - Assert.True(fixture.Presentation.OnStateAccepted(RollbackFixture.Guid)); - Assert.True(fixture.Live.TryApplyState( - new SetState.Parsed( - RollbackFixture.Guid, - (uint)PhysicsStateFlags.ReportCollisions, - 1, - 3), - out _, - out _)); - Assert.True(fixture.Presentation.OnStateAccepted(RollbackFixture.Guid)); - Assert.True(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - - fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.SourceLandblock)); - - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.Equal(1, fixture.ShadowSyncs); - Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void DeferredRollback_NewerLoadedPlacementTransfersShadowUntilResolution( - bool resolutionSucceeds) - { - const uint newerCell = 0xABB40001u; - const uint newerLandblock = 0xABB4FFFFu; - using var fixture = new RollbackFixture(); - CreateUnloadedSourceRollback(fixture); - fixture.ResolveSucceeds = resolutionSucceeds; - fixture.Spatial.AddLandblock(EmptyLandblock(newerLandblock)); - - AcceptNewerPosition(fixture, newerCell); - fixture.Controller.BeginPlacement(RollbackFixture.Guid, generation: 1); - fixture.VisibilityEdges.Clear(); - Assert.True(fixture.Live.RebucketLiveEntity(RollbackFixture.Guid, newerCell)); - - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.Equal(0, fixture.ShadowSyncs); - RemoteTeleportController.Result result = fixture.Controller.TryApply( - fixture.Remote, - fixture.Entity, - new Vector3(385f, 10f, 50f), - newerCell, - new Vector3(1f, 10f, 50f), - Quaternion.Identity, - gameTime: 7.0, - destinationProjectionVisible: true, - generation: 1, - positionSequence: 2); - - Assert.Equal(resolutionSucceeds, result.Applied); - Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.ShadowSyncs); - Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.Engine.ShadowObjects.TotalRegistered); - Assert.Equal(!resolutionSucceeds, - fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord record)); - Assert.Equal( - resolutionSucceeds ? newerCell : RollbackFixture.SourceCell, - record.FullCellId); - Assert.Equal(resolutionSucceeds, record.IsSpatiallyVisible); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void DeferredRollback_NewerPendingHydrationTransfersShadowUntilResolution( - bool resolutionSucceeds) - { - const uint newerCell = 0xACB40001u; - const uint newerLandblock = 0xACB4FFFFu; - using var fixture = new RollbackFixture(); - CreateUnloadedSourceRollback(fixture); - fixture.ResolveSucceeds = resolutionSucceeds; - - AcceptNewerPosition(fixture, newerCell); - fixture.Controller.BeginPlacement(RollbackFixture.Guid, generation: 1); - Assert.True(fixture.Live.RebucketLiveEntity(RollbackFixture.Guid, newerCell)); - Assert.True(fixture.Controller.TryApply( - fixture.Remote, - fixture.Entity, - new Vector3(577f, 10f, 50f), - newerCell, - new Vector3(1f, 10f, 50f), - Quaternion.Identity, - gameTime: 7.0, - destinationProjectionVisible: false, - generation: 1, - positionSequence: 2).Applied); - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.Equal(0, fixture.ShadowSyncs); - - fixture.Spatial.AddLandblock(EmptyLandblock(newerLandblock)); - - Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); - Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.ShadowSyncs); - Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.Engine.ShadowObjects.TotalRegistered); - Assert.Equal(!resolutionSucceeds, - fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord record)); - Assert.Equal( - resolutionSucceeds ? newerCell : RollbackFixture.SourceCell, - record.FullCellId); - Assert.Equal(resolutionSucceeds, record.IsSpatiallyVisible); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void HiddenPlacement_UnhideBeforeHydrationWaitsForResolution( - bool resolutionSucceeds) - { - const uint newerCell = 0xACB40001u; - const uint newerLandblock = 0xACB4FFFFu; - using var fixture = new RollbackFixture(); - CreateUnloadedSourceRollback(fixture); - fixture.ResolveSucceeds = resolutionSucceeds; - ApplyHidden(fixture, stateSequence: 2); - - AcceptNewerPosition(fixture, newerCell); - fixture.Controller.BeginPlacement(RollbackFixture.Guid, generation: 1); - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.True(fixture.Live.RebucketLiveEntity(RollbackFixture.Guid, newerCell)); - Assert.True(fixture.Controller.TryApply( - fixture.Remote, - fixture.Entity, - new Vector3(577f, 10f, 50f), - newerCell, - new Vector3(1f, 10f, 50f), - Quaternion.Identity, - gameTime: 7.0, - destinationProjectionVisible: false, - generation: 1, - positionSequence: 2).Applied); - - ApplyVisible(fixture, stateSequence: 3); - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.Equal(0, fixture.ShadowSyncs); - - fixture.Spatial.AddLandblock(EmptyLandblock(newerLandblock)); - - Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.ShadowSyncs); - Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.Engine.ShadowObjects.TotalRegistered); - Assert.Equal(!resolutionSucceeds, - fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void PendingPlacement_HideUnhideOscillationCannotRestoreBeforeResolution( - bool resolutionSucceeds) - { - const uint newerCell = 0xADB40001u; - const uint newerLandblock = 0xADB4FFFFu; - using var fixture = new RollbackFixture(); - CreateUnloadedSourceRollback(fixture); - fixture.ResolveSucceeds = resolutionSucceeds; - - AcceptNewerPosition(fixture, newerCell); - fixture.Controller.BeginPlacement(RollbackFixture.Guid, generation: 1); - Assert.True(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.True(fixture.Live.RebucketLiveEntity(RollbackFixture.Guid, newerCell)); - Assert.True(fixture.Controller.TryApply( - fixture.Remote, - fixture.Entity, - new Vector3(769f, 10f, 50f), - newerCell, - new Vector3(1f, 10f, 50f), - Quaternion.Identity, - gameTime: 7.0, - destinationProjectionVisible: false, - generation: 1, - positionSequence: 2).Applied); - - ApplyHidden(fixture, stateSequence: 2); - ApplyVisible(fixture, stateSequence: 3); - ApplyHidden(fixture, stateSequence: 4); - ApplyVisible(fixture, stateSequence: 5); - - Assert.True(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.Equal(0, fixture.ShadowSyncs); - Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); - - fixture.Spatial.AddLandblock(EmptyLandblock(newerLandblock)); - - Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); - Assert.False(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); - Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.ShadowSyncs); - Assert.Equal(resolutionSucceeds ? 1 : 0, fixture.Engine.ShadowObjects.TotalRegistered); - Assert.Equal(!resolutionSucceeds, - fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void PendingPlacement_SameBodyRuntimeRebindFinishesStableOwnership( - bool resolutionSucceeds) - { - using var fixture = new RollbackFixture(); - fixture.ResolveSucceeds = resolutionSucceeds; - Assert.True(fixture.ParkPending().Applied); - Assert.True(fixture.Controller.HasPending(RollbackFixture.Guid)); - Assert.True(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); - - Assert.Throws(() => - fixture.Live.SetRemoteMotionRuntime( - RollbackFixture.Guid, - new AcDream.Runtime.Physics.RemoteMotion())); - Assert.Throws(() => - fixture.Live.SetRemoteMotionRuntime( - RollbackFixture.Guid, - new BodyOnlyRemote(fixture.Remote.Body))); - - var replacement = new AcDream.Runtime.Physics.RemoteMotion(fixture.Remote.Body); - fixture.Live.SetRemoteMotionRuntime(RollbackFixture.Guid, replacement); - - fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); - - Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); - Assert.False(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.Equal(1, fixture.ShadowSyncs); - Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); - Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord stable)); - Assert.Same(replacement, stable.RemoteMotionRuntime); - Assert.Equal( - resolutionSucceeds - ? RollbackFixture.DestinationCell - : RollbackFixture.SourceCell, - stable.FullCellId); - Assert.Equal(stable.FullCellId, replacement.CellId); - } - - [Fact] - public void PendingPlacement_RuntimeClearRollsBackAndCompletesOwnership() - { - using var fixture = new RollbackFixture(); - Assert.True(fixture.ParkPending().Applied); - Assert.True(fixture.Live.ClearRemoteMotionRuntime(RollbackFixture.Guid)); - Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord retained)); - Assert.Same(fixture.Remote.Body, retained.PhysicsBody); - Assert.Throws(() => - fixture.Live.SetRemoteMotionRuntime( - RollbackFixture.Guid, - new AcDream.Runtime.Physics.RemoteMotion())); - Assert.Throws(() => - fixture.Live.SetRemoteMotionRuntime( - RollbackFixture.Guid, - new BodyOnlyRemote(fixture.Remote.Body))); - - fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); - - Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); - Assert.False(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.Equal(1, fixture.ShadowSyncs); - Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); - Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord stable)); - Assert.Equal(RollbackFixture.SourceCell, stable.FullCellId); - Assert.Null(stable.RemoteMotionRuntime); - } - - [Fact] - public void PendingPlacement_MutatedWrapperBodyIsDetachedAndCanonicalBodyRollsBack() - { - using var fixture = new RollbackFixture(); - Assert.True(fixture.ParkPending().Applied); - PhysicsBody canonicalBody = fixture.Remote.Body; - var mutable = new MutablePlacementRemote(canonicalBody); - fixture.Live.SetRemoteMotionRuntime(RollbackFixture.Guid, mutable); - - mutable.Body = new PhysicsBody(); - fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); - - Assert.False(fixture.Controller.HasPending(RollbackFixture.Guid)); - Assert.False(fixture.Presentation.HasActivePlacement(RollbackFixture.Guid)); - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.Equal(1, fixture.ShadowSyncs); - Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); - Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord stable)); - Assert.Same(canonicalBody, stable.PhysicsBody); - Assert.Null(stable.RemoteMotionRuntime); - Assert.Equal(RollbackFixture.SourcePosition, canonicalBody.Position); - Assert.Equal(RollbackFixture.SourcePosition, fixture.Entity.Position); - Assert.Equal(RollbackFixture.SourceCell, stable.FullCellId); - } - - [Fact] - public void RuntimeBinding_SnapshotsDynamicBodyGetterOncePerBoundary() - { - using var fixture = new RollbackFixture(); - PhysicsBody canonicalBody = fixture.Remote.Body; - var otherBody = new PhysicsBody(); - var remote = new AlternatingBodyRemote(canonicalBody, otherBody); - - fixture.Live.SetRemoteMotionRuntime(RollbackFixture.Guid, remote); - Assert.True(fixture.Live.TryGetRecord(RollbackFixture.Guid, out LiveEntityRecord record)); - Assert.Same(canonicalBody, record.PhysicsBody); - Assert.Same(remote, record.RemoteMotionRuntime); - - IRuntimeProjectile projectile = fixture.Live.BindProjectileRuntime( - RollbackFixture.Guid, - canonicalBody, - new ProjectileCollisionSphere(Vector3.Zero, 0.25f)); - - Assert.Same(canonicalBody, record.PhysicsBody); - Assert.Same(projectile, record.ProjectileRuntime); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void HiddenPlacement_HydrationHandsRestoreBackToUnhide( - bool resolutionSucceeds) - { - const uint newerCell = 0xACB40001u; - const uint newerLandblock = 0xACB4FFFFu; - using var fixture = new RollbackFixture(); - CreateUnloadedSourceRollback(fixture); - fixture.ResolveSucceeds = resolutionSucceeds; - ApplyHidden(fixture, stateSequence: 2); - - AcceptNewerPosition(fixture, newerCell); - fixture.Controller.BeginPlacement(RollbackFixture.Guid, generation: 1); - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.True(fixture.Live.RebucketLiveEntity(RollbackFixture.Guid, newerCell)); - Assert.True(fixture.Controller.TryApply( - fixture.Remote, - fixture.Entity, - new Vector3(577f, 10f, 50f), - newerCell, - new Vector3(1f, 10f, 50f), - Quaternion.Identity, - gameTime: 7.0, - destinationProjectionVisible: false, - generation: 1, - positionSequence: 2).Applied); - - fixture.Spatial.AddLandblock(EmptyLandblock(newerLandblock)); - - Assert.True(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.Equal(0, fixture.ShadowSyncs); - Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); - - ApplyVisible(fixture, stateSequence: 3); - - if (!resolutionSucceeds) - { - Assert.True(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.Equal(0, fixture.ShadowSyncs); - fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.SourceLandblock)); - } - - Assert.False(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.Equal(1, fixture.ShadowSyncs); - Assert.Equal(1, fixture.Engine.ShadowObjects.TotalRegistered); - } - - [Fact] - public void ResolverAcceptsNewerPosition_OlderPlacementReturnsSuperseded() - { - const uint guid = 0x70000060u; - const uint sourceCell = 0xA9B40039u; - const uint destinationCell = 0xAAB40001u; - var fixture = CreateLoadedRemoteFixture(guid, sourceCell); - Assert.True(fixture.Live.TryApplyPosition( - PositionUpdate(guid, destinationCell, x: 1f, sequence: 2), - isLocalPlayer: false, - forcePositionRotation: null, - currentLocalVelocity: null, - out _, - out _, - out _)); - Assert.True(fixture.Live.RebucketLiveEntity(guid, destinationCell)); - Assert.True(fixture.Live.TryGetRecord(guid, out LiveEntityRecord record)); - ulong outerPositionAuthority = record.PositionAuthorityVersion; - ulong outerVelocityAuthority = record.VelocityAuthorityVersion; - Vector3 newerWorldPosition = new(195f, 10f, 50f); - RemoteTeleportController? controller = null; - RemoteTeleportController.Result nestedResult = default; - bool nested = false; - controller = new RemoteTeleportController( - fixture.Engine, - fixture.Live, - (_, _) => (0.48f, 1.835f), - CellLocal, - (_, _, _) => { }, - (_, _, _) => { }, - (_, _) => { }, - resolvePlacement: (position, cell, _, _, _, _) => - { - if (!nested) - { - nested = true; - Assert.True(fixture.Live.TryApplyPosition( - PositionUpdate(guid, destinationCell, x: 3f, sequence: 3), - isLocalPlayer: false, - forcePositionRotation: null, - currentLocalVelocity: null, - out _, - out _, - out _)); - Assert.True(fixture.Live.TryGetRecord(guid, out var current)); - nestedResult = controller!.TryApply( - current, - current.PositionAuthorityVersion, - current.VelocityAuthorityVersion, - fixture.Remote, - fixture.Entity, - newerWorldPosition, - destinationCell, - new Vector3(3f, 10f, 50f), - Quaternion.Identity, - gameTime: 6.0, - destinationProjectionVisible: true, - generation: 1, - positionSequence: 3); - } - - return SuccessfulPlacement(position, cell); - }); - - RemoteTeleportController.Result outerResult = controller.TryApply( - record, - outerPositionAuthority, - outerVelocityAuthority, - fixture.Remote, - fixture.Entity, - new Vector3(193f, 10f, 50f), - destinationCell, - new Vector3(1f, 10f, 50f), - Quaternion.Identity, - gameTime: 5.0, - destinationProjectionVisible: true, - generation: 1, - positionSequence: 2); - - Assert.True(nestedResult.Applied); - Assert.True(nestedResult.ContactResolved); - Assert.True(outerResult.Superseded); - Assert.False(outerResult.Applied); - Assert.Equal(newerWorldPosition, fixture.Remote.Body.Position); - Assert.Equal(newerWorldPosition, fixture.Entity.Position); - controller.Dispose(); - } - - [Fact] - public void ResolverAcceptsIndependentState_PositionPlacementStillCommits() - { - const uint guid = 0x70000061u; - const uint sourceCell = 0xA9B40039u; - const uint destinationCell = 0xAAB40001u; - var fixture = CreateLoadedRemoteFixture(guid, sourceCell); - Assert.True(fixture.Live.TryApplyPosition( - PositionUpdate(guid, destinationCell, x: 1f, sequence: 2), - isLocalPlayer: false, - forcePositionRotation: null, - currentLocalVelocity: null, - out _, - out _, - out _)); - Assert.True(fixture.Live.RebucketLiveEntity(guid, destinationCell)); - Assert.True(fixture.Live.TryGetRecord(guid, out LiveEntityRecord record)); - ulong positionAuthority = record.PositionAuthorityVersion; - ulong velocityAuthority = record.VelocityAuthorityVersion; - bool stateAccepted = false; - using var controller = new RemoteTeleportController( - fixture.Engine, - fixture.Live, - (_, _) => (0.48f, 1.835f), - CellLocal, - (_, _, _) => { }, - (_, _, _) => { }, - (_, _) => { }, - resolvePlacement: (position, cell, _, _, _, _) => - { - if (!stateAccepted) - { - stateAccepted = true; - Assert.True(fixture.Live.TryApplyState( - new SetState.Parsed( - guid, - (uint)(PhysicsStateFlags.Hidden - | PhysicsStateFlags.ReportCollisions), - 1, - 2), - out _)); - } - return SuccessfulPlacement(position, cell); - }); - Vector3 destination = new(193f, 10f, 50f); - - RemoteTeleportController.Result result = controller.TryApply( - record, - positionAuthority, - velocityAuthority, - fixture.Remote, - fixture.Entity, - destination, - destinationCell, - new Vector3(1f, 10f, 50f), - Quaternion.Identity, - gameTime: 5.0, - destinationProjectionVisible: true, - generation: 1, - positionSequence: 2); - - Assert.True(stateAccepted); - Assert.True(result.Applied); - Assert.True(result.ContactResolved); - Assert.False(result.Superseded); - Assert.Equal(destination, fixture.Remote.Body.Position); - Assert.Equal(destination, fixture.Entity.Position); - Assert.True(record.FinalPhysicsState.HasFlag(PhysicsStateFlags.Hidden)); - Assert.Equal(record.FinalPhysicsState, fixture.Remote.Body.State); - } - - [Fact] - public void TeardownCallbackFailure_StillForgetsPendingBeforeGuidReuse() - { - const uint guid = 0x70000004u; - const uint sourceCell = 0xA9B40039u; - const uint destinationCell = 0xAAB40001u; - RemoteTeleportController? controller = null; - LiveEntityRecord? owner = null; - bool cleanupAfterFailureRan = false; - var resources = new DelegateLiveEntityResourceLifecycle( - _ => { }, - _ => LiveEntityTeardown.Run( - [ - () => throw new InvalidOperationException("effect teardown failed"), - () => controller!.Forget(owner!), - () => cleanupAfterFailureRan = true, - ])); - var spatial = new GpuWorldState(); - spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); - var live = LiveEntityRuntimeFixture.Create(spatial, resources); - live.RegisterLiveEntity(Spawn(guid, sourceCell)); - WorldEntity entity = live.MaterializeLiveEntity( - guid, - sourceCell, - id => new WorldEntity - { - Id = id, - ServerGuid = guid, - SourceGfxObjOrSetupId = 0x02000001u, - Position = new Vector3(191f, 10f, 50f), - Rotation = Quaternion.Identity, - MeshRefs = Array.Empty(), - })!; - var remote = new AcDream.Runtime.Physics.RemoteMotion(); - remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position); - live.SetRemoteMotionRuntime(guid, remote); - Assert.True(live.TryGetRecord(guid, out owner)); - controller = new RemoteTeleportController( - BuildEngine(includeDestination: false), - live, - (_, _) => (0.48f, 1.835f), - CellLocal, - (_, _, _) => { }, - (_, _, _) => { }, - (_, _) => { }); - - Assert.True(live.RebucketLiveEntity(guid, destinationCell)); - Assert.True(controller.TryApply( - remote, - entity, - new Vector3(193f, 10f, 50f), - destinationCell, - new Vector3(1f, 10f, 50f), - Quaternion.Identity, - gameTime: 5.0, - destinationProjectionVisible: false, - generation: 1, - positionSequence: 1).Applied); - Assert.True(controller.HasPending(guid)); - - Assert.Throws(() => live.UnregisterLiveEntity( - new DeleteObject.Parsed(guid, InstanceSequence: 1), - isLocalPlayer: false)); - - Assert.True(cleanupAfterFailureRan); - Assert.False(controller.HasPending(guid)); - Assert.False(live.TryGetRecord(guid, out _)); - - LiveEntityRegistrationResult replacement = - live.RegisterLiveEntity(Spawn(guid, sourceCell)); - Assert.True(replacement.LogicalRegistrationCreated); - Assert.False(controller.HasPending(guid)); - controller.Dispose(); - } - - private static PhysicsEngine BuildEngine(bool includeDestination) - { - var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; - engine.AddLandblock( - 0xA9B4FFFFu, - FlatTerrain(), - Array.Empty(), - Array.Empty(), - 0f, - 0f); - if (includeDestination) - AddDestination(engine); - return engine; - } - - private static LoadedRemoteFixture CreateLoadedRemoteFixture( - uint guid, - uint sourceCell) - { - var spatial = new GpuWorldState(); - spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); - spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu)); - var live = LiveEntityRuntimeFixture.Create(spatial, new Resources()); - live.RegisterLiveEntity(Spawn(guid, sourceCell)); - WorldEntity entity = live.MaterializeLiveEntity( - guid, - sourceCell, - id => new WorldEntity - { - Id = id, - ServerGuid = guid, - SourceGfxObjOrSetupId = 0x02000001u, - Position = new Vector3(191f, 10f, 50f), - Rotation = Quaternion.Identity, - MeshRefs = Array.Empty(), - })!; - var remote = new AcDream.Runtime.Physics.RemoteMotion(); - remote.Body.SnapToCell(sourceCell, entity.Position, entity.Position); - live.SetRemoteMotionRuntime(guid, remote); - return new LoadedRemoteFixture( - live, - entity, - remote, - BuildEngine(includeDestination: true)); - } - - private static WorldSession.EntityPositionUpdate PositionUpdate( - uint guid, - uint cell, - float x, - ushort sequence) => new( - guid, - new CreateObject.ServerPosition( - cell, - x, - 10f, - 50f, - 1f, - 0f, - 0f, - 0f), - null, - null, - true, - 1, - sequence, - 0, - 0); - - private static ResolveResult SuccessfulPlacement(Vector3 position, uint cell) => new( - position, - cell, - IsOnGround: true, - InContact: true, - OnWalkable: true, - ContactPlane: new Plane(Vector3.UnitZ, 0f), - ContactPlaneCellId: cell); - - private readonly record struct LoadedRemoteFixture( - LiveEntityRuntime Live, - WorldEntity Entity, - AcDream.Runtime.Physics.RemoteMotion Remote, - PhysicsEngine Engine); - - private static void AddDestination(PhysicsEngine engine) => - engine.AddLandblock( - 0xAAB4FFFFu, - FlatTerrain(), - Array.Empty(), - Array.Empty(), - 192f, - 0f); - - private static TerrainSurface FlatTerrain() - { - var heights = new byte[81]; - var table = new float[256]; - Array.Fill(table, 50f); - return new TerrainSurface(heights, table); - } - - private static Vector3 CellLocal(Vector3 world, uint cell) - { - int lbX = (int)(cell >> 24); - return world - new Vector3((lbX - 0xA9) * 192f, 0f, 0f); - } - - private static LoadedLandblock EmptyLandblock(uint id) => - new(id, new LandBlock(), Array.Empty()); - - private static WorldSession.EntitySpawn Spawn(uint guid, uint cell) - { - var position = new CreateObject.ServerPosition( - cell, 191f, 10f, 50f, 1f, 0f, 0f, 0f); - var physics = new PhysicsSpawnData( - (uint)(PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions), - position, - null, - null, - 0x02000001u, - 0x09000001u, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - null, - new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1)); - return new WorldSession.EntitySpawn( - Guid: guid, - Position: position, - SetupTableId: 0x02000001u, - AnimPartChanges: Array.Empty(), - TextureChanges: Array.Empty(), - SubPalettes: Array.Empty(), - BasePaletteId: null, - ObjScale: 1f, - Name: "remote", - ItemType: null, - MotionState: null, - MotionTableId: 0x09000001u, - PhysicsState: physics.RawState, - InstanceSequence: 1, - MovementSequence: 1, - ServerControlSequence: 1, - PositionSequence: 1, - Physics: physics); - } - - private static void CreateUnloadedSourceRollback(RollbackFixture fixture) - { - Assert.True(fixture.ParkPending().Applied); - fixture.Spatial.RemoveLandblock(RollbackFixture.SourceLandblock); - fixture.Spatial.AddLandblock(EmptyLandblock(RollbackFixture.DestinationLandblock)); - Assert.True(fixture.Presentation.HasDeferredShadowRestore(RollbackFixture.Guid)); - Assert.Equal(0, fixture.ShadowSyncs); - Assert.Equal(0, fixture.Engine.ShadowObjects.TotalRegistered); - } - - private static void AcceptNewerPosition(RollbackFixture fixture, uint cellId) - { - Assert.True(fixture.Live.TryApplyPosition( - new WorldSession.EntityPositionUpdate( - RollbackFixture.Guid, - new CreateObject.ServerPosition( - cellId, 1f, 10f, 50f, 1f, 0f, 0f, 0f), - null, - null, - true, - 1, - 2, - 0, - 0), - isLocalPlayer: false, - forcePositionRotation: null, - currentLocalVelocity: null, - out _, - out _, - out _)); - } - - private static void ApplyHidden(RollbackFixture fixture, ushort stateSequence) - { - Assert.True(fixture.Live.TryApplyState( - new SetState.Parsed( - RollbackFixture.Guid, - (uint)(PhysicsStateFlags.Hidden | PhysicsStateFlags.ReportCollisions), - 1, - stateSequence), - out _, - out _)); - Assert.True(fixture.Presentation.OnStateAccepted(RollbackFixture.Guid)); - } - - private static void ApplyVisible(RollbackFixture fixture, ushort stateSequence) - { - Assert.True(fixture.Live.TryApplyState( - new SetState.Parsed( - RollbackFixture.Guid, - (uint)PhysicsStateFlags.ReportCollisions, - 1, - stateSequence), - out _, - out _)); - Assert.True(fixture.Presentation.OnStateAccepted(RollbackFixture.Guid)); - } - - private sealed class RollbackFixture : IDisposable - { - internal const uint Guid = 0x70000010u; - internal const uint SourceCell = 0xA9B40039u; - internal const uint SourceLandblock = 0xA9B4FFFFu; - internal const uint DestinationCell = 0xAAB40001u; - internal const uint DestinationLandblock = 0xAAB4FFFFu; - internal static readonly Vector3 SourcePosition = new(191f, 10f, 50f); - - internal RollbackFixture(bool celllessSource = false) - { - Spatial.AddLandblock(EmptyLandblock(SourceLandblock)); - Live = LiveEntityRuntimeFixture.Create(Spatial, new Resources()); - Live.RegisterLiveEntity(Spawn(Guid, SourceCell)); - Entity = Live.MaterializeLiveEntity( - Guid, - SourceCell, - id => new WorldEntity - { - Id = id, - ServerGuid = Guid, - SourceGfxObjOrSetupId = 0x02000001u, - Position = SourcePosition, - Rotation = Quaternion.Identity, - MeshRefs = Array.Empty(), - })!; - Remote = new AcDream.Runtime.Physics.RemoteMotion(); - Remote.Body.SnapToCell( - celllessSource ? 0u : SourceCell, - SourcePosition, - SourcePosition); - Remote.Body.TransientState = TransientStateFlags.Contact - | TransientStateFlags.OnWalkable; - Remote.CellId = celllessSource ? 0u : SourceCell; - Live.SetRemoteMotionRuntime(Guid, Remote); - Engine.ShadowObjects.Register( - Entity.Id, - Entity.SourceGfxObjOrSetupId, - SourcePosition, - Quaternion.Identity, - 0.48f, - 0f, - 0f, - SourceLandblock, - ShadowCollisionType.Cylinder, - 1.835f, - seedCellId: SourceCell, - isStatic: false); - Assert.True(Engine.ShadowObjects.Suspend(Entity.Id)); - - Live.ProjectionVisibilityChanged += OnVisibilityChanged; - Presentation = new LiveEntityPresentationController( - Live, - Engine.ShadowObjects, - (_, _, _) => true, - new LiveEntityPartArrayEnterWorldPort(_ => { }), - liveCenter: () => (0xA9, 0xB4), - onShadowRestored: _ => ShadowSyncs++); - Assert.True(Presentation.OnLiveEntityReady(Guid)); - Controller = new RemoteTeleportController( - Engine, - Live, - (_, _) => (0.48f, 1.835f), - CellLocal, - (resolvedEntity, resolvedRemote, resolvedCell) => - { - ShadowSyncs++; - uint landblock = (resolvedCell & 0xFFFF0000u) | 0xFFFFu; - Engine.ShadowObjects.Register( - resolvedEntity.Id, - resolvedEntity.SourceGfxObjOrSetupId, - resolvedRemote.Position, - resolvedRemote.Orientation, - 0.48f, - 0f, - 0f, - landblock, - ShadowCollisionType.Cylinder, - 1.835f, - seedCellId: resolvedCell, - isStatic: false); - }, - (guid, generation, deferShadowRestore) => - Presentation.CompleteAuthoritativePlacement( - guid, - generation, - deferShadowRestore), - (guid, generation) => - Presentation.BeginAuthoritativePlacement(guid, generation), - resolvePlacement: (position, cell, _, _, _, _) => - ResolveSucceeds - ? new ResolveResult( - position, - cell, - IsOnGround: true, - InContact: true, - OnWalkable: true, - ContactPlane: new Plane(Vector3.UnitZ, 0f), - ContactPlaneCellId: cell) - : new ResolveResult(position, cell, IsOnGround: false, Ok: false)); - - if (celllessSource) - Assert.True(Live.WithdrawLiveEntityProjection(Guid)); - } - - internal GpuWorldState Spatial { get; } = new(); - internal LiveEntityRuntime Live { get; } - internal WorldEntity Entity { get; } - internal AcDream.Runtime.Physics.RemoteMotion Remote { get; } - internal PhysicsEngine Engine { get; } = new() { DataCache = new PhysicsDataCache() }; - internal RemoteTeleportController Controller { get; } - internal LiveEntityPresentationController Presentation { get; } - internal int ShadowSyncs { get; private set; } - internal bool PresentationVisible { get; private set; } = true; - internal List VisibilityEdges { get; } = []; - internal bool ResolveSucceeds { get; set; } - - internal RemoteTeleportController.Result ParkPending() - { - Controller.BeginPlacement(Guid, generation: 1); - Assert.True(Live.RebucketLiveEntity(Guid, DestinationCell)); - return Controller.TryApply( - Remote, - Entity, - new Vector3(193f, 10f, 50f), - DestinationCell, - new Vector3(1f, 10f, 50f), - Quaternion.Identity, - gameTime: 5.0, - destinationProjectionVisible: false, - generation: 1, - positionSequence: 1); - } - - public void Dispose() - { - Live.ProjectionVisibilityChanged -= OnVisibilityChanged; - Controller.Dispose(); - Presentation.Dispose(); - } - - private void OnVisibilityChanged(LiveEntityRecord _, bool visible) - { - PresentationVisible = visible; - VisibilityEdges.Add(visible); - } - } -} diff --git a/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs b/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs deleted file mode 100644 index 731f5396..00000000 --- a/tests/AcDream.App.Tests/Physics/RemoteTeleportPlacementTests.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System.Numerics; -using AcDream.App.Physics; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.App.Tests.Physics; - -public sealed class RemoteTeleportPlacementTests -{ - [Fact] - public void Apply_HardSnapsFullFrameAndPhysicsClock() - { - var body = new PhysicsBody - { - Orientation = Quaternion.Identity, - LastUpdateTime = 2.0, - }; - body.SnapToCell( - 0x01010001u, - new Vector3(3f, 4f, 5f), - new Vector3(3f, 4f, 5f)); - var remote = new AcDream.Runtime.Physics.RemoteMotion(body); - Quaternion orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f); - - RemoteTeleportPlacement.Apply( - remote, - body, - new ResolveResult( - new Vector3(210f, 220f, 12f), - 0x02020123u, - IsOnGround: false), - new Vector3(18f, 28f, 12f), - orientation, - 17.5, - previousContact: body.InContact, - previousOnWalkable: body.OnWalkable); - - Assert.Equal(new Vector3(210f, 220f, 12f), body.Position); - Assert.Equal(0x02020123u, body.CellPosition.ObjCellId); - Assert.Equal(new Vector3(18f, 28f, 12f), body.CellPosition.Frame.Origin); - Assert.Equal(orientation, body.Orientation); - Assert.Equal(17.5, body.LastUpdateTime); - Assert.True(body.InWorld); - } - - [Fact] - public void Apply_InstallsPlacementDerivedContactAndSynchronizesAirborneState() - { - var velocity = new Vector3(1f, 2f, 3f); - var body = new PhysicsBody - { - Velocity = velocity, - TransientState = TransientStateFlags.Active, - State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, - }; - var contactPlane = new Plane(Vector3.UnitZ, 0f); - var remote = new AcDream.Runtime.Physics.RemoteMotion(body) - { - Airborne = true, - }; - - RemoteTeleportPlacement.Apply( - remote, - body, - new ResolveResult( - new Vector3(200f, 200f, 50f), - 0x02020001u, - IsOnGround: true, - InContact: true, - OnWalkable: true, - ContactPlane: contactPlane, - ContactPlaneCellId: 0x02020001u), - new Vector3(8f, 8f, 50f), - Quaternion.Identity, - 9.0, - previousContact: false, - previousOnWalkable: false); - - Assert.Equal(Vector3.Zero, body.Velocity); - Assert.True(body.InContact); - Assert.True(body.OnWalkable); - Assert.True(body.ContactPlaneValid); - Assert.Equal(contactPlane, body.ContactPlane); - Assert.False(remote.Airborne); - } - - [Fact] - public void Apply_PendingHydrationUsesPreservedSourceFlagsBeforeCollisionResponse() - { - var originalVelocity = new Vector3(-3f, 0f, 0f); - var body = new PhysicsBody - { - Velocity = originalVelocity, - State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, - }; - // ParkPending deliberately clears these flags while the destination - // landblock is absent. SetPositionInternal must still receive the - // grounded source edge captured before parking. - body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); - var remote = new AcDream.Runtime.Physics.RemoteMotion(body) - { - Airborne = true, - }; - - RemoteTeleportPlacement.Apply( - remote, - body, - new ResolveResult( - new Vector3(200f, 200f, 50f), - 0x02020001u, - IsOnGround: true, - CollisionNormalValid: true, - CollisionNormal: Vector3.UnitX, - InContact: true, - OnWalkable: true, - ContactPlane: new Plane(Vector3.UnitZ, 0f), - ContactPlaneCellId: 0x02020001u), - new Vector3(8f, 8f, 50f), - Quaternion.Identity, - 9.0, - previousContact: true, - previousOnWalkable: true); - - // Staying on walkable ground suppresses reflection. If the parked - // false/false flags were used, the -X velocity would reflect to +X. - Assert.Equal(originalVelocity, body.Velocity); - Assert.True(body.OnWalkable); - Assert.False(remote.Airborne); - } - - [Fact] - public void Apply_PendingGroundToSteepContact_RestoresSourceWalkabilityForFirstAcceleration() - { - var body = new PhysicsBody - { - Omega = new Vector3(0f, 0f, 3f), - State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, - }; - // The unloaded-destination parking frame cleared both bits. Retail - // restores the captured source OnWalkable bit through the first - // calc_acceleration, which clears grounded angular drift, before - // set_on_walkable installs the steep destination's false value. - body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); - var remote = new AcDream.Runtime.Physics.RemoteMotion(body); - - RemoteTeleportPlacement.Apply( - remote, - body, - new ResolveResult( - new Vector3(200f, 200f, 50f), - 0x02020001u, - IsOnGround: false, - InContact: true, - OnWalkable: false, - ContactPlane: new Plane(Vector3.Normalize(new Vector3(1f, 0f, 0.2f)), 0f), - ContactPlaneCellId: 0x02020001u), - new Vector3(8f, 8f, 50f), - Quaternion.Identity, - 9.0, - previousContact: true, - previousOnWalkable: true); - - Assert.Equal(Vector3.Zero, body.Omega); - Assert.True(body.InContact); - Assert.False(body.OnWalkable); - Assert.True(remote.Airborne); - } - - [Fact] - public void Apply_RejectsMalformedPlacementWithoutMutatingBody() - { - var original = new Vector3(7f, 8f, 9f); - var body = new PhysicsBody { Position = original }; - var remote = new AcDream.Runtime.Physics.RemoteMotion(body); - - Assert.Throws(() => - RemoteTeleportPlacement.Apply( - remote, - body, - new ResolveResult( - new Vector3(float.NaN, 0f, 0f), - 0, - IsOnGround: false, - Ok: false), - Vector3.Zero, - Quaternion.Identity, - double.NaN, - previousContact: false, - previousOnWalkable: false)); - - Assert.Equal(original, body.Position); - Assert.Equal(0u, body.CellPosition.ObjCellId); - } -} diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 0251bf1d..e048ab06 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -850,7 +850,6 @@ public sealed class CurrentGameRuntimeAdapterTests LiveLiveness = noop, RuntimeGeneration = _ => { }, SessionIdentityPresentation = _ => { }, - RemoteTeleport = noop, NetworkEffects = noop, AnimationHookFrames = noop, LivePresentation = noop, diff --git a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs index 98167806..368c78a1 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityLifecycleStressTests.cs @@ -263,12 +263,8 @@ public sealed class LiveEntityLifecycleStressTests Assert.Equal(1, fixture.Presentation.DeferredShadowRestoreCount); fixture.BeginDeferredTeleport(); - Assert.Equal(1, fixture.Teleport.PendingPlacementCount); - Assert.Equal(1, fixture.Presentation.ActivePlacementCount); fixture.LoadDestination(); - Assert.Equal(0, fixture.Teleport.PendingPlacementCount); - Assert.Equal(0, fixture.Presentation.ActivePlacementCount); Assert.Equal(1, fixture.Presentation.DeferredShadowRestoreCount); fixture.ApplyVisible(); @@ -293,10 +289,8 @@ public sealed class LiveEntityLifecycleStressTests Assert.Equal(0, fixture.ParticleSink.ActiveBindingCount); Assert.Equal(0, fixture.Poses.Count); Assert.Equal(0, fixture.Engine.ShadowObjects.RetainedRegistrationCount); - Assert.Equal(0, fixture.Teleport.PendingPlacementCount); Assert.Equal(0, fixture.Presentation.ReadyOwnerCount); Assert.Equal(0, fixture.Presentation.DeferredShadowRestoreCount); - Assert.Equal(0, fixture.Presentation.ActivePlacementCount); Assert.Equal(0, fixture.Spatial.PendingLiveEntityCount); Assert.Equal(0, fixture.Spatial.PendingBucketCount); } @@ -590,7 +584,6 @@ public sealed class LiveEntityLifecycleStressTests EntityEffectController? effects = null; LiveEntityPresentationController? presentation = null; - RemoteTeleportController? teleport = null; Runtime = LiveEntityRuntimeFixture.Create( Spatial, new DelegateLiveEntityResourceLifecycle( @@ -609,7 +602,6 @@ public sealed class LiveEntityLifecycleStressTests { effects?.OnLiveEntityUnregistered(record); presentation?.Forget(record); - teleport?.Forget(record); if (record.WorldEntity is { } entity) Engine.ShadowObjects.Deregister(entity.Id); }); @@ -669,25 +661,6 @@ public sealed class LiveEntityLifecycleStressTests liveCenter: () => (1, 1)); Assert.True(Presentation.OnLiveEntityReady(Guid)); - Teleport = teleport = new RemoteTeleportController( - Engine, - Runtime, - (_, _) => (0.48f, 1.835f), - (position, _) => position, - (_, _, _) => { }, - (guid, generation, defer) => - Presentation.CompleteAuthoritativePlacement(guid, generation, defer), - (guid, generation) => - Presentation.BeginAuthoritativePlacement(guid, generation), - resolvePlacement: (position, cell, _, _, _, _) => new ResolveResult( - position, - cell, - IsOnGround: true, - InContact: true, - OnWalkable: true, - ContactPlane: new Plane(Vector3.UnitZ, 0f), - ContactPlaneCellId: cell)); - Runtime.ProjectionVisibilityChanged += OnProjectionVisibilityChanged; _hookQueue = new AnimationHookFrameQueue(Router, Poses); } @@ -703,7 +676,6 @@ public sealed class LiveEntityLifecycleStressTests internal EntityEffectController Effects { get; } internal WorldEntity Entity { get; } internal LiveEntityPresentationController Presentation { get; } - internal RemoteTeleportController Teleport { get; } internal List TypedScripts { get; } = []; internal int RecallHookCount { get; private set; } @@ -735,25 +707,27 @@ public sealed class LiveEntityLifecycleStressTests Assert.True(Presentation.OnStateAccepted(Guid)); } + // C4 route 4b-3 deleted the standalone RemoteTeleportController this + // step used to drive. The scenario this fixture exists to cover — + // Hidden/DeferredShadowRestore correctness across a landblock churn + // while an entity is mid-teleport — does not depend on that + // controller's own bookkeeping (BeginAuthoritativePlacement/ + // CompleteAuthoritativePlacement are confirmed dead: their only + // production caller was the deleted presentation adapter). This + // reproduces the same observable state the canonical teleport arm's + // tail produces: the destination cell adopted and the body/entity + // moved to the destination pose, still Hidden. internal void BeginDeferredTeleport() { _destinationCell = _currentCell == CellOne ? CellTwo : CellOne; Assert.False(Spatial.IsLoaded((_destinationCell & 0xFFFF0000u) | 0xFFFFu)); - Teleport.BeginPlacement(Guid, generation: 1); Assert.True(Runtime.RebucketLiveEntity(Guid, _destinationCell)); - RemoteTeleportController.Result result = Teleport.TryApply( - _remote, - Entity, - new Vector3(Entity.Position.X + 3f, Entity.Position.Y, Entity.Position.Z), - _destinationCell, - Entity.Position, - Quaternion.Identity, - gameTime: _stateSequence, - destinationProjectionVisible: false, - generation: 1, - positionSequence: 1); - Assert.True(result.Applied); - Assert.False(result.ContactResolved); + var destination = new Vector3( + Entity.Position.X + 3f, Entity.Position.Y, Entity.Position.Z); + _remote.Body.Position = destination; + _remote.CellId = _destinationCell; + Entity.SetPosition(destination); + Entity.ParentCellId = _destinationCell; } internal void LoadDestination() @@ -797,7 +771,6 @@ public sealed class LiveEntityLifecycleStressTests { Clear(); Runtime.ProjectionVisibilityChanged -= OnProjectionVisibilityChanged; - Teleport.Dispose(); Presentation.Dispose(); } diff --git a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs index fc52de54..dc5c0caa 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityPresentationControllerTests.cs @@ -596,51 +596,12 @@ public sealed class LiveEntityPresentationControllerTests controller.Dispose(); } - [Fact] - public void ActivePlacement_IsGenerationScopedAndClearsOnTeardownAndReset() - { - Fixture fixture = new(PhysicsStateFlags.ReportCollisions); - Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid)); - Assert.True(fixture.Controller.BeginAuthoritativePlacement(Fixture.Guid, 1)); - Assert.True(fixture.Controller.HasActivePlacement(Fixture.Guid)); - Assert.True(fixture.Runtime.TryGetRecord(Fixture.Guid, out LiveEntityRecord oldRecord)); - - fixture.Controller.Forget(oldRecord); - Assert.True(fixture.Runtime.UnregisterLiveEntity( - new DeleteObject.Parsed(Fixture.Guid, 1), - isLocalPlayer: false)); - Assert.False(fixture.Controller.HasActivePlacement(Fixture.Guid)); - - fixture.Runtime.RegisterLiveEntity(Fixture.Spawn( - PhysicsStateFlags.ReportCollisions, - instanceSequence: 2)); - fixture.Runtime.MaterializeLiveEntity( - Fixture.Guid, - 0x01010001u, - id => new WorldEntity - { - Id = id, - ServerGuid = Fixture.Guid, - SourceGfxObjOrSetupId = 0x02000001u, - Position = new Vector3(10f, 10f, 5f), - Rotation = Quaternion.Identity, - MeshRefs = Array.Empty(), - }); - Assert.True(fixture.Controller.OnLiveEntityReady(Fixture.Guid)); - - Assert.False(fixture.Controller.CompleteAuthoritativePlacement( - Fixture.Guid, - generation: 1, - deferShadowRestore: true)); - Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid)); - Assert.True(fixture.Controller.BeginAuthoritativePlacement(Fixture.Guid, 2)); - Assert.True(fixture.Controller.HasActivePlacement(Fixture.Guid)); - - fixture.Controller.Clear(); - - Assert.False(fixture.Controller.HasActivePlacement(Fixture.Guid)); - Assert.False(fixture.Controller.HasDeferredShadowRestore(Fixture.Guid)); - } + // C4 route 4b-3: ActivePlacement_IsGenerationScopedAndClearsOnTeardownAndReset + // deleted — BeginAuthoritativePlacement/CompleteAuthoritativePlacement/ + // HasActivePlacement/DeferShadowRestore and the backing + // _activePlacementOwners set are dead (their only production caller was + // the deleted RemoteTeleportPlacementPresentation); see + // docs/research/2026-08-04-c4-route-4b-3-contract.md's deletion inventory. private sealed class Fixture { diff --git a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs index 9252d083..b3ae14e6 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityRuntimeTests.cs @@ -1785,11 +1785,25 @@ public sealed class LiveEntityRuntimeTests out AcceptedPhysicsTimestamps timestamps)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); Assert.False(timestamps.TeleportAdvanced); - Assert.True(timestamps.TeleportHookRequired); + // C4 route 4b-3 (D1): the pickup left the record cell-less + // (FullCellId == 0, unwield-to-3D), and TryApplyPosition measures + // that PRE-merge value — the classifier's remote cell-less predicate + // reads this, not the post-merge canonical.FullCellId. + Assert.Equal(0u, timestamps.PreMergeCommittedCellId); } + /// + /// C4 route 4b-3 (D1): before this slice, losing spatial visibility alone + /// (no cell-less body, no fresh TELEPORT_TS) flagged + /// projectionRequiresTeleportHook — a presentation predicate with + /// no retail analogue (the deleted D1 evidence-chain arm). This is the + /// regression guard: a record that is still resident at a real cell + /// (materialized at 0x01010001u) but has lost spatial projection reports + /// its HONEST pre-merge committed cell, not a fabricated cell-less + /// signal — visibility is presentation-only now. + /// [Fact] - public void PositionFromPendingProjection_RequiresTeleportHookWithEqualTeleportStamp() + public void PositionFromPendingProjection_ReportsTheHonestPreMergeCellNotVisibility() { const uint guid = 0x70000044u; var spatial = new GpuWorldState(); @@ -1824,7 +1838,7 @@ public sealed class LiveEntityRuntimeTests Assert.Equal(PositionTimestampDisposition.Apply, disposition); Assert.False(timestamps.TeleportAdvanced); - Assert.True(timestamps.TeleportHookRequired); + Assert.Equal(0x01010001u, timestamps.PreMergeCommittedCellId); } [Fact] diff --git a/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs b/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs index 4d9bdf28..347b23c7 100644 --- a/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs +++ b/tests/AcDream.App.Tests/World/RuntimeEntityOwnershipTests.cs @@ -122,9 +122,7 @@ public sealed class RuntimeEntityOwnershipTests AssertExactKeyFields( typeof(LiveEntityPresentationController), "_readyOwners", - "_suspendedShadowOwners", - "_activePlacementOwners"); - AssertExactKeyFields(typeof(RemoteTeleportController), "_pending"); + "_suspendedShadowOwners"); AssertExactKeyFields(typeof(LiveRenderProjectionJournal), "_byKey"); AssertExactKeyFields( typeof(EntityEffectController), @@ -185,7 +183,6 @@ public sealed class RuntimeEntityOwnershipTests [ typeof(LiveEntityProjectionStore), typeof(LiveEntityPresentationController), - typeof(RemoteTeleportController), typeof(LiveRenderProjectionJournal), typeof(LiveEntityLightController), typeof(EquippedChildRenderController), diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs index 28527d3e..82149e71 100644 --- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs @@ -911,8 +911,6 @@ public sealed class UpdateFrameOrchestratorTests typeof(LiveSessionLocalPhysicsTimestampPublisher), typeof(AcDream.App.Physics.LiveEntityNetworkUpdateController), typeof(AcDream.App.Rendering.LiveEntityPartArrayLifecycle), - typeof(AcDream.App.Physics.RemoteShadowPlacementSynchronizer), - typeof(AcDream.App.Physics.RemoteTeleportPlacementPresentation), typeof(AcDream.App.Net.LiveEntitySessionController), ]; foreach (Type owner in typedProductionOwners) @@ -965,8 +963,11 @@ public sealed class UpdateFrameOrchestratorTests StringComparison.Ordinal); Assert.Contains("d.WorldOrigin.GetCenter", livePresentation, StringComparison.Ordinal); - Assert.Contains("d.WorldOrigin.CellLocalForSeed", livePresentation, - StringComparison.Ordinal); + // C4 route 4b-3 deleted the sole consumer of + // d.WorldOrigin.CellLocalForSeed in this file — the standalone + // RemoteTeleportController construction. LiveWorldOriginState. + // CellLocalForSeed itself is not deleted (LocalPlayerTeleportController + // still uses it). Assert.DoesNotContain("CreateLiveSessionEventRouter", source, StringComparison.Ordinal); Assert.Contains("_world.EntitySession.CreateSink()", sessionRuntime, diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 2dca2dce..075fe290 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -508,7 +508,6 @@ public sealed class HeadlessSessionHostTests isLocalPlayer: true, forcePositionRotation: Quaternion.Identity, currentLocalVelocity: controller.BodyVelocity, - projectionRequiresTeleportHook: false, acknowledgeProjection: null, out PositionTimestampDisposition disposition, out _, diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs index 7fcd8795..296d6741 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateContinuationExecutorTests.cs @@ -1239,7 +1239,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests WorldSession.EntityPositionUpdate update = PositionUpdate( guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 15f); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: true, null, null, false, null, + update, isLocalPlayer: true, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); @@ -1286,7 +1286,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests WorldSession.EntityPositionUpdate update = PositionUpdate( guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: true, null, null, true, null, + update, isLocalPlayer: true, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); @@ -1344,7 +1344,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests WorldSession.EntityPositionUpdate update = PositionUpdate( guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f, isGrounded: true); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: false, null, null, false, null, + update, isLocalPlayer: false, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); @@ -1391,7 +1391,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 15f, isGrounded: true); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: true, null, null, false, null, + update, isLocalPlayer: true, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); @@ -1432,7 +1432,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f, isGrounded: false); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: false, null, null, false, null, + update, isLocalPlayer: false, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); @@ -1468,7 +1468,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests WorldSession.EntityPositionUpdate update = PositionUpdate( guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: false, null, null, false, null, + update, isLocalPlayer: false, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); @@ -1558,7 +1558,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests WorldSession.EntityPositionUpdate update = PositionUpdate( guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 1, positionX: 40f); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: true, null, null, false, null, + update, isLocalPlayer: true, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition); @@ -1626,7 +1626,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests WorldSession.EntityPositionUpdate update = PositionUpdate( guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: false, null, null, false, null, + update, isLocalPlayer: false, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); @@ -1727,7 +1727,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests WorldSession.EntityPositionUpdate update = PositionUpdate( guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: false, null, null, false, null, + update, isLocalPlayer: false, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); @@ -3940,7 +3940,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests WorldSession.EntityPositionUpdate update = PositionUpdate( guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: true, null, null, true, null, out _, out _, out _)); + update, isLocalPlayer: true, null, null, null, out _, out _, out _)); RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( canonical, lease.Token, NoContact, out _); @@ -4007,7 +4007,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests WorldSession.EntityPositionUpdate update = PositionUpdate( guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: true, null, null, true, null, out _, out _, out _)); + update, isLocalPlayer: true, null, null, null, out _, out _, out _)); RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( canonical, lease.Token, NoContact, out _); @@ -4072,7 +4072,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests WorldSession.EntityPositionUpdate update = PositionUpdate( guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: true, null, null, true, null, out _, out _, out _)); + update, isLocalPlayer: true, null, null, null, out _, out _, out _)); RuntimeInitialCreateExecutionStatus status = lifetime.InitialCreateExecution.Execute( canonical, lease.Token, NoContact, out _); @@ -4149,7 +4149,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests WorldSession.EntityPositionUpdate update = PositionUpdate( guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: false, null, null, false, null, + update, isLocalPlayer: false, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); @@ -4289,7 +4289,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: true, null, null, true, null, + update, isLocalPlayer: true, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); @@ -4392,7 +4392,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests guid, positionSequence: 2, teleportSequence: 1, forcePositionSequence: 0, positionX: 40f); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: true, null, null, true, null, + update, isLocalPlayer: true, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); @@ -4750,7 +4750,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 15f, isGrounded: true); Assert.True(lifetime.TryApplyPosition( - update, isLocalPlayer: true, null, null, false, null, + update, isLocalPlayer: true, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); @@ -4806,7 +4806,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests secondGuid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 16f, isGrounded: true); Assert.True(lifetime.TryApplyPosition( - secondUpdate, isLocalPlayer: true, null, null, false, null, + secondUpdate, isLocalPlayer: true, null, null, null, out PositionTimestampDisposition secondDisposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, secondDisposition); RuntimeInitialCreateExecutionReceipt secondReceipt = RunToCompletion( @@ -4847,7 +4847,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests guid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 15f, isGrounded: true); Assert.True(unbound.TryApplyPosition( - update, isLocalPlayer: true, null, null, false, null, + update, isLocalPlayer: true, null, null, null, out PositionTimestampDisposition disposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, disposition); @@ -4896,7 +4896,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests nearGuid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f, isGrounded: true); Assert.True(lifetime.TryApplyPosition( - nearUpdate, isLocalPlayer: false, null, null, false, null, + nearUpdate, isLocalPlayer: false, null, null, null, out PositionTimestampDisposition nearDisposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, nearDisposition); var farStruct = new RuntimeInitialCreateExecutionInputs( @@ -4936,7 +4936,7 @@ public sealed class RuntimeInitialCreateContinuationExecutorTests farGuid, positionSequence: 2, teleportSequence: 0, forcePositionSequence: 0, positionX: 25f, isGrounded: true); Assert.True(lifetime.TryApplyPosition( - farUpdate, isLocalPlayer: false, null, null, false, null, + farUpdate, isLocalPlayer: false, null, null, null, out PositionTimestampDisposition farDisposition, out _, out _)); Assert.Equal(PositionTimestampDisposition.Apply, farDisposition); diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs index e5344af5..c5965a0c 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeInitialCreateResidenceStateTests.cs @@ -532,7 +532,6 @@ public sealed class RuntimeInitialCreateResidenceStateTests isLocalPlayer: false, forcePositionRotation: null, currentLocalVelocity: null, - projectionRequiresTeleportHook: false, acknowledgeProjection: _ => callbacks++, out PositionTimestampDisposition disposition, out _, @@ -1126,7 +1125,6 @@ public sealed class RuntimeInitialCreateResidenceStateTests teleportSequence: 0, forcePositionSequence: 0, positionX: 20f, - projectionRequiresTeleportHook: false, () => callbacks++); ApplyQueuedPosition( lifetime, @@ -1135,7 +1133,6 @@ public sealed class RuntimeInitialCreateResidenceStateTests teleportSequence: 1, forcePositionSequence: 0, positionX: 30f, - projectionRequiresTeleportHook: true, () => callbacks++); ApplyQueuedPosition( lifetime, @@ -1144,7 +1141,6 @@ public sealed class RuntimeInitialCreateResidenceStateTests teleportSequence: 2, forcePositionSequence: 0, positionX: 40f, - projectionRequiresTeleportHook: false, () => callbacks++); Assert.True(lifetime.TryGetInitialCreateResidence( @@ -1167,10 +1163,15 @@ public sealed class RuntimeInitialCreateResidenceStateTests Assert.Equal([(ushort)0, (ushort)1, (ushort)2], retained.Continuations.Select( static item => PositionAction(item).Position!.Value.TeleportSequence)); - Assert.False(PositionAction(retained.Continuations[0]) - .AcceptedTimestamps.TeleportHookRequired); - Assert.False(PositionAction(retained.Continuations[1]) - .AcceptedTimestamps.TeleportHookRequired); + // C4 route 4b-3 (D1): the deferred initial-residence FIFO + // (RuntimeEntities.TryAcceptDeferredPosition) never runs + // TryApplyPosition's pre-merge-cell measurement — that is the direct + // accepted-Position path's own bookkeeping. A queued continuation's + // timestamps therefore carry the honest "not measured here" value. + Assert.Null(PositionAction(retained.Continuations[0]) + .AcceptedTimestamps.PreMergeCommittedCellId); + Assert.Null(PositionAction(retained.Continuations[1]) + .AcceptedTimestamps.PreMergeCommittedCellId); } [Fact] @@ -1199,7 +1200,6 @@ public sealed class RuntimeInitialCreateResidenceStateTests isLocalPlayer: false, forcePositionRotation: null, currentLocalVelocity: null, - projectionRequiresTeleportHook: false, acknowledgeProjection: null, out PositionTimestampDisposition disposition, out _, @@ -1219,7 +1219,6 @@ public sealed class RuntimeInitialCreateResidenceStateTests teleportSequence: 0, forcePositionSequence: 0, positionX: 25f, - projectionRequiresTeleportHook: false, callback: null, isLocalPlayer: false); Assert.True(lifetime.TryGetInitialCreateResidence( @@ -1374,7 +1373,6 @@ public sealed class RuntimeInitialCreateResidenceStateTests teleportSequence: 1, forcePositionSequence: 0, positionX: 55f, - projectionRequiresTeleportHook: true, callback: null); Assert.Equal( RuntimeInitialCreateResidenceCompletionStatus.Completed, @@ -1555,7 +1553,6 @@ public sealed class RuntimeInitialCreateResidenceStateTests isLocalPlayer: false, forcePositionRotation: null, currentLocalVelocity: null, - projectionRequiresTeleportHook: true, acknowledgeProjection: _ => callbacks++, out _, out _, @@ -2557,7 +2554,6 @@ public sealed class RuntimeInitialCreateResidenceStateTests ushort teleportSequence, ushort forcePositionSequence, float positionX, - bool projectionRequiresTeleportHook, Action? callback, bool isLocalPlayer = true) { @@ -2572,7 +2568,6 @@ public sealed class RuntimeInitialCreateResidenceStateTests isLocalPlayer, forcePositionRotation: Quaternion.Identity, currentLocalVelocity: new Vector3(1f, 2f, 3f), - projectionRequiresTeleportHook, acknowledgeProjection: callback is null ? null : _ => callback(), diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteFirstEntryStateTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteFirstEntryStateTests.cs index abf19a47..7b839439 100644 --- a/tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteFirstEntryStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteFirstEntryStateTests.cs @@ -478,7 +478,6 @@ public sealed class RuntimeRemoteFirstEntryStateTests isLocalPlayer: false, forcePositionRotation: null, currentLocalVelocity: null, - projectionRequiresTeleportHook: false, acknowledgeProjection: null, out PositionTimestampDisposition disposition, out _, diff --git a/tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteTeleportClassificationTests.cs b/tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteTeleportClassificationTests.cs new file mode 100644 index 00000000..feb94919 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Entities/RuntimeRemoteTeleportClassificationTests.cs @@ -0,0 +1,242 @@ +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Entities; + +/// +/// C4 route 4b-3 (D1): the load-bearing evidence chain for the whole slice — +/// measures the +/// PRE-merge committed cell (beforeCell/wasCellless) before +/// RefreshSnapshot stamps the accepted wire cell onto the canonical +/// record, and +/// must read THAT value, not the post-merge canonical.FullCellId every +/// other classifier caller reads. Reverting the fix (feeding the classifier +/// canonical.FullCellId after the merge instead of the pre-merge value) +/// must fail +/// — that is the ONE test that discriminates the fix from the shipped dead +/// predicate, because both predicates agree whenever the pre-merge cell truly +/// was zero. +/// +public sealed class RuntimeRemoteTeleportClassificationTests +{ + private const uint Cell = 0x0101FFFFu; + private const uint OtherCell = 0x0102FFFFu; + + [Fact] + public void CellLessRecord_ClassifiesSetPosition_EvenWithoutATeleportAdvance() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new RuntimeGenerationToken(1), static () => 1UL); + const uint guid = 0x70005001u; + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(Spawn(guid, Cell, instance: 1)).Canonical!; + Assert.Equal(Cell, canonical.FullCellId); + + // The unwield-to-3D shape (AP-137's citation): a canonical withdrawal + // zeroes the committed cell without any wire packet. + lifetime.Entities.SetFullCell(canonical, 0u, 0u); + Assert.Equal(0u, canonical.FullCellId); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, OtherCell, positionSequence: 2, teleportSequence: 0); + + Assert.True(lifetime.TryApplyPosition( + update, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out AcceptedPhysicsTimestamps timestamps)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + // The measured pre-merge value is the honest 0 — not a fabrication, + // and not re-read after the merge (which would already show OtherCell). + Assert.Equal(0u, timestamps.PreMergeCommittedCellId); + Assert.False(timestamps.TeleportAdvanced); + + Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord after)); + RuntimeAuthoritativePositionRoute? route = lifetime.ClassifyRemoteAcceptedPosition( + after, update, disposition, timestamps, playerDistance: 10f); + + Assert.NotNull(route); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPosition, + route!.Value.Disposition); + Assert.True( + (route.Value.SetPositionFlags & PhysicsSetPositionFlags.Teleport) != 0); + Assert.True(RuntimeRemoteTeleportPosition.OwnsTeleportPlacement(route)); + } + + /// + /// The discriminator (contract's test plan item 4, companion half): the + /// SAME shape but WITHOUT the cell-less reset and WITHOUT a TELEPORT_TS + /// advance must NOT classify SetPosition. Under the shipped dead + /// predicate (reading canonical.FullCellId AFTER the merge) this + /// would ALSO pass, because the merge always stamps a nonzero wire cell — + /// the predicate was unreachable, not merely conservative. This test only + /// distinguishes the fix once + /// establishes the positive case is reachable at all. + /// + [Fact] + public void CompanionTest_NonzeroPreMergeCellWithNoTeleportAdvance_DoesNotClassifySetPosition() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new RuntimeGenerationToken(1), static () => 1UL); + const uint guid = 0x70005002u; + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(Spawn(guid, Cell, instance: 1)).Canonical!; + Assert.Equal(Cell, canonical.FullCellId); + // Deliberately NO SetFullCell(0, 0) — the record stays resident. + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, OtherCell, positionSequence: 2, teleportSequence: 0); + + Assert.True(lifetime.TryApplyPosition( + update, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out AcceptedPhysicsTimestamps timestamps)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + // Honest pre-merge value: still resident at the ORIGINAL cell, not + // the just-merged wire cell (which would be OtherCell) and not 0. + Assert.Equal(Cell, timestamps.PreMergeCommittedCellId); + Assert.False(timestamps.TeleportAdvanced); + + Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord after)); + RuntimeAuthoritativePositionRoute? route = lifetime.ClassifyRemoteAcceptedPosition( + after, update, disposition, timestamps, playerDistance: 10f); + + Assert.NotNull(route); + Assert.NotEqual( + RuntimeAuthoritativePositionDisposition.SetPosition, + route!.Value.Disposition); + Assert.False(RuntimeRemoteTeleportPosition.OwnsTeleportPlacement(route)); + } + + /// + /// A fresh TELEPORT_TS still classifies SetPosition even when the + /// pre-merge cell was resident — the OTHER half of retail's predicate + /// (newer_event(TELEPORT_TS) || this_1->cell == 0), unaffected by + /// D1's plumbing change. + /// + [Fact] + public void FreshTeleportTimestamp_ClassifiesSetPosition_WithAResidentPreMergeCell() + { + using var lifetime = new RuntimeEntityObjectLifetime(); + lifetime.BindEventContext(static () => new RuntimeGenerationToken(1), static () => 1UL); + const uint guid = 0x70005003u; + RuntimeEntityRecord canonical = + lifetime.RegisterEntity(Spawn(guid, Cell, instance: 1)).Canonical!; + Assert.Equal(Cell, canonical.FullCellId); + + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid, OtherCell, positionSequence: 2, teleportSequence: 5); + + Assert.True(lifetime.TryApplyPosition( + update, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + acknowledgeProjection: null, + out PositionTimestampDisposition disposition, + out _, + out AcceptedPhysicsTimestamps timestamps)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + Assert.Equal(Cell, timestamps.PreMergeCommittedCellId); + Assert.True(timestamps.TeleportAdvanced); + + Assert.True(lifetime.Entities.TryGetActive(guid, out RuntimeEntityRecord after)); + RuntimeAuthoritativePositionRoute? route = lifetime.ClassifyRemoteAcceptedPosition( + after, update, disposition, timestamps, playerDistance: 10f); + + Assert.NotNull(route); + Assert.Equal( + RuntimeAuthoritativePositionDisposition.SetPosition, + route!.Value.Disposition); + Assert.True(RuntimeRemoteTeleportPosition.OwnsTeleportPlacement(route)); + } + + private static WorldSession.EntityPositionUpdate PositionUpdate( + uint guid, + uint cellId, + ushort positionSequence, + ushort teleportSequence) => + new( + guid, + new CreateObject.ServerPosition( + cellId, 12f, 14f, 7f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: positionSequence, + TeleportSequence: teleportSequence, + ForcePositionSequence: 0); + + private static WorldSession.EntitySpawn Spawn( + uint guid, + uint cellId, + ushort instance) + { + var position = new CreateObject.ServerPosition( + cellId, 10f, 20f, 5f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: 1, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: instance); + var physics = new PhysicsSpawnData( + RawState: 0x408u, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "remote-teleport-classification", + null, + null, + 0x09000001u, + PhysicsState: 0x408u, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs index d3d410ad..f76387cb 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFirstEntryStateTests.cs @@ -241,7 +241,6 @@ public sealed class RuntimeLocalPlayerFirstEntryStateTests isLocalPlayer: true, forcePositionRotation: null, currentLocalVelocity: null, - projectionRequiresTeleportHook: true, acknowledgeProjection: null, out PositionTimestampDisposition disposition, out _, diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs index 3e3b9035..5b336467 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs @@ -1139,7 +1139,6 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests isLocalPlayer: true, forcePositionRotation: null, currentLocalVelocity: null, - projectionRequiresTeleportHook: false, acknowledgeProjection: null, out _, out _, diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteFarSnapPositionTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteFarSnapPositionTests.cs index b7acd2a8..59d2deb2 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteFarSnapPositionTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteFarSnapPositionTests.cs @@ -207,13 +207,11 @@ public sealed class RuntimeRemoteFarSnapPositionTests RuntimeRemoteFarSnapPosition.ResolveArm( Classify(hasContact: true, playerDistance: 200f))); - // The acdream-only leftovers, all four shapes. This is the stated - // policy: they are NOT far. Reading "not Interpolate" as "far" is - // route 4a's own review finding, one level up. - Assert.Equal( - RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp, - RuntimeRemoteFarSnapPosition.ResolveArm( - Classify(hasContact: true, playerDistance: 10f, committedCellId: 0u))); + // The acdream-only leftovers left in THIS arm after C4 route 4b-3 + // moved the cell-less/fresh-teleport shapes onto the teleport arm + // (RuntimeRemoteTeleportPosition.OwnsTeleportPlacement — see that + // class's own tests). What remains: no classification at all, and + // the two rejections. Assert.Equal( RuntimeRemoteAcceptedPositionArm.UnroutedCatchUp, RuntimeRemoteFarSnapPosition.ResolveArm( @@ -230,31 +228,6 @@ public sealed class RuntimeRemoteFarSnapPositionTests RuntimeRemoteFarSnapPosition.ResolveArm(null)); } - // ── The single ConstrainTo site's gate ────────────────────────────────── - - [Fact] - public void OwnsAfterOperationConstraint_CoversTheThreeArmsThatRunAnOperation() - { - // Retail arms @0x00454272 for every nonzero MoveOrTeleport return. - // The airborne no-op returns 0 @0x0051636D, so it is excluded — and - // that exclusion is route 4a's, carried forward unchanged. - Assert.True(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( - Classify(hasContact: true, playerDistance: 10f))); - Assert.True(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( - Classify(hasContact: true, playerDistance: 200f))); - Assert.True(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( - Classify(hasContact: false, playerDistance: 10f))); - - // The leftovers still arm through the App's legacy pre-operation - // site; claiming them here would double-arm them. - Assert.False(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( - Classify(hasContact: true, playerDistance: 10f, committedCellId: 0u))); - Assert.False(RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint( - Classify(hasContact: true, playerDistance: float.NaN))); - Assert.False( - RuntimeRemoteFarSnapPosition.OwnsAfterOperationConstraint(null)); - } - private static RuntimeAuthoritativePositionAuthority Authority( PositionTimestampDisposition disposition, ushort previousTeleport, diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteadyStatePositionTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteadyStatePositionTests.cs index f155a080..891f2f15 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteadyStatePositionTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteadyStatePositionTests.cs @@ -291,83 +291,45 @@ public sealed class RuntimeRemoteSteadyStatePositionTests Assert.Equal(0f, constraint.ConstraintPosOffset, 3); } - [Fact] - public void TryArmConstraintAfterOperation_ArmsForTheNearInterpolateBranch() - { - (RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost( - new Vector3(1f, 2f, 3f)); - - Assert.True(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - Classify(hasContact: true, playerDistance: 10f), - remote)); - Assert.True(host.PositionManager.Constraint?.IsConstrained); - } - - [Fact] - public void TryArmConstraintAfterOperation_SkipsTheAirborneNoOperation() - { - // MoveOrTeleport returns 0 for arg4 == 0, so HandleReceivedPosition's - // `if (MoveOrTeleport(...) != 0)` never reaches ConstrainTo. - (RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost( - new Vector3(1f, 2f, 3f)); - - Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - Classify(hasContact: false, playerDistance: 10f), - remote)); - Assert.Null(host.PositionManager.Constraint); - } - /// - /// C4 route 4b-2: the far branch moved onto the post-operation arm with - /// route 4a's two. Retail's `MoveOrTeleport` returns 1 @0x005163E8 there, - /// so `HandleReceivedPosition`'s single `ConstrainTo` @0x00454272 runs — - /// and the App's legacy PRE-operation call site now reads the same - /// predicate and skips it, so it is armed exactly once. + /// C4 route 4b-3 (D4): the complete arm-count partition, proof obligation + /// 3 — one assertion per row of the contract's partition table, on the + /// OBSERVABLE (whether ConstrainTo actually armed), not the code + /// shape. + /// and + /// arm unconditionally (retail discards the placement error and returns + /// 1 either way, @0x00516438/@0x005163E8); + /// arms + /// too — it is only ever reached here when the caller's free-flight + /// carve-out has already confirmed the body is in contact; only + /// + /// does not (retail's arg4 == 0 branch returns 0 @0x0051636D). /// - [Fact] - public void TryArmConstraintAfterOperation_ArmsForTheFarSnapBranch() + [Theory] + [InlineData("TeleportPlacement", true)] + [InlineData("FarSnapPlacement", true)] + [InlineData("NearInterpolate", true)] + [InlineData("UnroutedCatchUp", true)] + [InlineData("AirborneNoOperation", false)] + public void TryArmConstraintAfterOperation_MatchesTheCompletePartition( + string armName, + bool expectedArmed) { + // RuntimeRemoteAcceptedPositionArm is internal; xUnit's [InlineData] + // requires public-visible argument types, so the arm travels as its + // name and is parsed back here. + var arm = (RuntimeRemoteAcceptedPositionArm)Enum.Parse( + typeof(RuntimeRemoteAcceptedPositionArm), armName); (RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost( new Vector3(1f, 2f, 3f)); - RuntimeAuthoritativePositionRoute far = - Classify(hasContact: true, playerDistance: 200f); + bool armed = RuntimeRemoteSteadyStatePosition + .TryArmConstraintAfterOperation(arm, remote); + + Assert.Equal(expectedArmed, armed); Assert.Equal( - RuntimeAuthoritativePositionDisposition.SetPositionSimple, - far.Disposition); - - Assert.True(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - far, - remote)); - Assert.True(host.PositionManager.Constraint?.IsConstrained); - } - - [Fact] - public void TryArmConstraintAfterOperation_SkipsClassificationsNoArmOwns() - { - // The cell-less half, the two rejections, and "no classification at - // all" still arm their leash through the untouched legacy - // PRE-operation call site, not through here. Arming here too would - // double-arm them. - (RemoteMotion remote, EntityPhysicsHost host) = MakeRemoteWithHost( - new Vector3(1f, 2f, 3f)); - - Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - Classify(hasContact: true, playerDistance: 10f, committedCellId: 0u), - remote)); - Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - Classify( - hasContact: true, - playerDistance: 10f, - disposition: PositionTimestampDisposition.Rejected), - remote)); - Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - Classify(hasContact: true, playerDistance: float.NaN), - remote)); - Assert.False(RuntimeRemoteSteadyStatePosition.TryArmConstraintAfterOperation( - null, - remote)); - Assert.Null(host.PositionManager.Constraint); + expectedArmed, + host.PositionManager.Constraint?.IsConstrained == true); } private static RuntimeAuthoritativePositionRoute Classify( diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs index 99f8c789..f475acaa 100644 --- a/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs @@ -1772,7 +1772,6 @@ public sealed class RuntimeSetPositionStateTests isLocalPlayer: false, forcePositionRotation: null, currentLocalVelocity: null, - projectionRequiresTeleportHook: false, acknowledgeProjection: null, out _, out _, @@ -3194,7 +3193,6 @@ public sealed class RuntimeSetPositionStateTests isLocalPlayer: false, forcePositionRotation: null, currentLocalVelocity: null, - projectionRequiresTeleportHook: false, acknowledgeProjection: null, out _, out _, diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs index 8fcec810..08c31dab 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs @@ -1237,7 +1237,6 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests isLocalPlayer: true, forcePositionRotation: controller.BodyOrientation, currentLocalVelocity: controller.BodyVelocity, - projectionRequiresTeleportHook: false, acknowledgeProjection: null, out PositionTimestampDisposition disposition, out _, @@ -1306,7 +1305,6 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests Teleport: teleport, ForcePosition: 1, TeleportAdvanced: false, - TeleportHookRequired: false, PreviousTeleport: teleport); /// diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs index 91a5f06c..06fec946 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeRemotePlacementDriveControllerTests.cs @@ -1935,6 +1935,327 @@ public sealed class RuntimeRemotePlacementDriveControllerTests } } + // ── C4 route 4b-3: the teleport/cell-less arm ─────────────────────────── + // TryExecuteAcceptedRemotePosition/SubmitAndResolve's own mechanics + // (commit/park/reject dispatch, currency, ledger convergence) are already + // exhaustively proven above for SetPosition-disposition routes — several + // far-snap tests already construct SetPosition routes because + // TryExecuteAcceptedRemotePosition is disposition-agnostic. What is new + // here is ApplyAcceptedRemoteTeleport's OWN behaviour: the route guard, + // the store_position fallback wired for the teleport disposition + // specifically, and (D3) that it does NOT clear the interpolation queue + // itself — unlike the far arm, retail's clear for this branch lives + // inside teleport_hook, not in MoveOrTeleport. + + /// + /// Mirrors + /// through the teleport arm specifically. + /// + [Fact] + public void Teleport_Committed_PlacesFromCanonicalDestination() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003040u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + destination); + + RuntimeRemotePlacementExecutionStatus status = + drive.ApplyAcceptedRemoteTeleport(record, remote, route); + + Assert.Equal(RuntimeRemotePlacementExecutionStatus.Committed, status); + Assert.Equal(destination + new Vector3(192f, 0f, 0f), body.Position); + DrainPlacementFifo(lifetime); + Assert.Equal( + 0, lifetime.Physics.CaptureOwnership().SetPositionOperationCount); + AssertConverged(lifetime); + } + + /// + /// Invariant 1: a teleport whose destination the service window declines + /// still advances the body to the accepted destination — retail's + /// no-transition store_position branch, identical to the far arm's + /// own fallback. + /// + [Fact] + public void Teleport_RefusedByServiceWindow_StillStoresTheDestinationPose() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + // The world frame must still be published (store_position resolves + // through it); the service window is what refuses — mirrors + // FarSnap_ClearsTheInterpolationQueue_IndependentlyOfThePlacementOutcome's + // setup, which is the far arm's own Refused-fallback test. + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003041u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + Vector3 positionBefore = body.Position; + // Deliberately does not Allow(DestinationLandblock) — the service + // window refuses. + var window = new FakeServiceWindow(); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + destination); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Refused, + drive.ApplyAcceptedRemoteTeleport(record, remote, route)); + + Assert.NotEqual(positionBefore, body.Position); + Assert.True(body.InWorld); + AssertConverged(lifetime); + } + + /// + /// The non-storing half of retail's partition, through the teleport arm: + /// the engine's own sweep refused the destination + /// (RejectedByPlacement), so the body must be left exactly where + /// it was — mirrors + /// . + /// + [Fact] + public void Teleport_EngineRefusedTheDestination_LeavesTheBodyWhereItWas() + { + PhysicsEngine engine = FlatEngine(); + using var lifetime = new RuntimeEntityObjectLifetime(engine); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003042u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + Vector3 positionBefore = body.Position; + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + engine.TransitionCellCollisionTestHook = + static (_, _, _, _) => TransitionState.Collided; + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + destination); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.RejectedByPlacement, + drive.ApplyAcceptedRemoteTeleport(record, remote, route)); + + Assert.Equal(positionBefore, body.Position); + Assert.NotEqual(destination + new Vector3(192f, 0f, 0f), body.Position); + AssertConverged(lifetime); + } + + /// + /// D3: unlike the far arm, ApplyAcceptedRemoteTeleport must NOT + /// clear the interpolation queue itself — the classifier's teleport + /// branch carries StopInterpolating: false on purpose, because + /// retail's clear for this branch lives inside teleport_hook's + /// PositionManager::StopInterpolating @0x00514EFD, which the + /// CALLER (ApplyRemoteContactRouting) runs before this method. If + /// this method also cleared the queue, the two would race on which side + /// "owns" the retail action. + /// + [Fact] + public void Teleport_DoesNotClearTheInterpolationQueueItself() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003043u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + remote.Interp.Enqueue( + new Vector3(40f, 40f, SpawnHeight), + Quaternion.Identity, + isMovingTo: false, + currentBodyPosition: body.Position, + currentBodyOrientation: body.Orientation); + Assert.True(remote.Interp.IsActive); + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + new Vector3(12f, 14f, SpawnHeight)); + Assert.False(route.StopInterpolating); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Committed, + drive.ApplyAcceptedRemoteTeleport(record, remote, route)); + + Assert.True(remote.Interp.IsActive); + DrainPlacementFifo(lifetime); + } + + /// + /// The teleport arm must never be handed a route it does not own — the + /// caller selects with + /// RuntimeRemoteTeleportPosition.OwnsTeleportPlacement. Mirrors + /// . + /// + [Fact] + public void Teleport_ThrowsForARouteThisArmDoesNotOwn() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003044u); + AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + RuntimeRemotePlacementDriveController drive = + CreateDrive(lifetime, new FakeServiceWindow()); + + foreach (RuntimeAuthoritativePositionDisposition disposition in + new[] + { + RuntimeAuthoritativePositionDisposition.SetPositionSimple, + RuntimeAuthoritativePositionDisposition.Interpolate, + RuntimeAuthoritativePositionDisposition.NoPositionOperation, + RuntimeAuthoritativePositionDisposition.RejectedData, + }) + { + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, disposition, DestinationCell); + Assert.Throws( + () => drive.ApplyAcceptedRemoteTeleport(record, remote, route)); + } + } + + /// + /// Test-plan item 7 / contract D3's currency rule, "now for the teleport + /// arm" — the R5 shape + /// + /// already pins for ApplyAcceptedRemoteFarSnap. Retail's + /// store_position fallback is the SAME method + /// (StoreAcceptedDestinationPose) both arms call through, but that + /// sharing is exactly why it needs its own pin: a future edit could special- + /// case one arm's call site without the other, and only a same-shaped test + /// for each caller catches that. Reaches the stale-record state the same + /// deterministic way — dropping the record from the active directory while + /// its body/key/snapshot stay exactly as the packet left them, so the + /// currency guard (not a null check) is what's under test. + /// + [Fact] + public void Teleport_SupersededIncarnation_DoesNotStoreThroughTheStaleRecord() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord(lifetime, 0x70003045u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + Vector3 positionBefore = body.Position; + // Deliberately does not Allow(DestinationLandblock) — the service + // window refuses, landing on the SAME store_position fallback the far + // arm's currency test exercises. + RuntimeRemotePlacementDriveController drive = + CreateDrive(lifetime, new FakeServiceWindow()); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute route = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + destination); + + Assert.True(lifetime.Entities.RemoveActive(record)); + Assert.False(lifetime.Entities.IsCurrent(record)); + Assert.NotNull(record.PhysicsBody); + Assert.NotNull(record.Key); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Refused, + drive.ApplyAcceptedRemoteTeleport(record, remote, route)); + + Assert.Equal(positionBefore, body.Position); + Assert.NotEqual(destination + new Vector3(192f, 0f, 0f), body.Position); + AssertConverged(lifetime); + } + + /// + /// Test-plan item 8 / proof obligation 2: teardown, session reset, and + /// generation change all converge RemotePlacementDrivePendingCount + /// to zero — driven through + /// itself, not assumed transitively from + /// + /// (which seeds its retained entry through the lower shared + /// TryExecuteAcceptedRemotePosition entry point, bypassing the + /// teleport arm's own route-ownership check entirely). DetachRoute + /// is the one production convergence hook this controller exposes — + /// GameRuntime's teardown, session reset, and generation-change + /// paths all funnel through it, exactly as they do for the far arm's own + /// already-covered case; there is no separate per-cause API to test + /// independently at this layer. + /// + /// + /// Retains via the SAME "retryable preparation" shape + /// + /// uses (an unresolved Setup collision reports Contention and + /// parks an entry in _pending for the cadence pump) — so the + /// convergence this test proves is genuinely draining a LIVE retained + /// teleport retry, not an already-empty ledger. + /// + /// + [Fact] + public void Teleport_LedgerConverges_AfterDetachRouteClearsARetainedRetry() + { + using var lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + CommitLandblockCollision(lifetime, DestinationLandblock); + RuntimeEntityRecord record = CreateRemoteRecord( + lifetime, 0x70003046u, setupTableId: 0x02000001u); + PhysicsBody body = AttachBody(lifetime, record, SourceCell); + RemoteMotion remote = lifetime.Physics.GetOrCreateRemoteMotion(record); + Vector3 positionBefore = body.Position; + var window = new FakeServiceWindow(); + window.Allow(DestinationLandblock); + RuntimeRemotePlacementDriveController drive = CreateDrive(lifetime, window); + var route = new object(); + drive.AttachRoute(route); + + var destination = new Vector3(12f, 14f, SpawnHeight); + RuntimeAuthoritativePositionRoute teleportRoute = MakeRoute( + record, + RuntimeAuthoritativePositionDisposition.SetPosition, + DestinationCell, + destination, + stopInterpolating: true); + + Assert.Equal( + RuntimeRemotePlacementExecutionStatus.Contention, + drive.ApplyAcceptedRemoteTeleport(record, remote, teleportRoute)); + // The retained retry is live (not assumed) — a second call through + // the arm proves it, mirroring the far arm's own + // FarSnap_RetryablePreparation test's shape. + Assert.Equal(1, drive.PendingCount); + Assert.Equal( + 1, lifetime.CaptureOwnership().RemotePlacementDrivePendingCount); + Assert.Equal( + destination + new Vector3(192f, 0f, 0f), body.Position); + Assert.NotEqual(positionBefore, body.Position); + + drive.DetachRoute(route); + + Assert.Equal(0, drive.PendingCount); + Assert.Equal( + 0, lifetime.CaptureOwnership().RemotePlacementDrivePendingCount); + AssertConverged(lifetime); + } + // ── Fixture ────────────────────────────────────────────────────────── ///